@scotthuang/engram 0.6.8 → 0.7.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 +79 -2
- package/dist/src/__tests__/profile.test.js +66 -22
- package/dist/src/__tests__/profile.test.js.map +1 -1
- package/dist/src/index.js +76 -8
- package/dist/src/index.js.map +1 -1
- package/dist/src/profile.d.ts +22 -5
- package/dist/src/profile.js +106 -14
- package/dist/src/profile.js.map +1 -1
- package/dist/src/settle.js +16 -14
- package/dist/src/settle.js.map +1 -1
- package/package.json +1 -1
- package/dist/bm25.d.ts +0 -60
- package/dist/bm25.js +0 -271
- package/dist/bm25.js.map +0 -1
- package/dist/config.d.ts +0 -47
- package/dist/config.js +0 -83
- package/dist/config.js.map +0 -1
- package/dist/image-store.d.ts +0 -146
- package/dist/image-store.js +0 -418
- package/dist/image-store.js.map +0 -1
- package/dist/index.d.ts +0 -7
- package/dist/index.js +0 -1138
- package/dist/index.js.map +0 -1
- package/dist/logger.d.ts +0 -32
- package/dist/logger.js +0 -106
- package/dist/logger.js.map +0 -1
- package/dist/profile.d.ts +0 -37
- package/dist/profile.js +0 -107
- package/dist/profile.js.map +0 -1
- package/dist/recall.d.ts +0 -98
- package/dist/recall.js +0 -729
- package/dist/recall.js.map +0 -1
- package/dist/settle.d.ts +0 -83
- package/dist/settle.js +0 -675
- package/dist/settle.js.map +0 -1
- package/dist/vector.d.ts +0 -66
- package/dist/vector.js +0 -275
- package/dist/vector.js.map +0 -1
package/dist/index.js
DELETED
|
@@ -1,1138 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Engram Plugin - Main Entry
|
|
3
|
-
*
|
|
4
|
-
* 分层语义记忆系统 OpenClaw Plugin
|
|
5
|
-
* kind: "memory" (exclusive slot)
|
|
6
|
-
*/
|
|
7
|
-
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
8
|
-
// OpenClaw Plugin API 没有提供类型定义,需要使用 any
|
|
9
|
-
import { parseConfig } from "./config.js";
|
|
10
|
-
import { RecallEngine } from "./recall.js";
|
|
11
|
-
import { ProfileManager } from "./profile.js";
|
|
12
|
-
import { VectorStore } from "./vector.js";
|
|
13
|
-
import { ImageStore } from "./image-store.js";
|
|
14
|
-
import { runSettlement, runMonthlySettle } from "./settle.js";
|
|
15
|
-
import { logger } from "./logger.js";
|
|
16
|
-
import { promises as fs } from "node:fs";
|
|
17
|
-
import path from "node:path";
|
|
18
|
-
import { createRequire } from "node:module";
|
|
19
|
-
const _require = createRequire(import.meta.url);
|
|
20
|
-
const PKG_VERSION = _require("../package.json").version;
|
|
21
|
-
// ---- 模块级单例(避免 OpenClaw 多次调用 register 时重复创建) ----
|
|
22
|
-
let singletonVectorStore = null;
|
|
23
|
-
let singletonRecallEngine = null;
|
|
24
|
-
let singletonProfileManager = null;
|
|
25
|
-
let singletonConfig = null;
|
|
26
|
-
let singletonWorkspaceDir = null;
|
|
27
|
-
let singletonImageStore = null;
|
|
28
|
-
// ---- Auto-Settle 状态 ----
|
|
29
|
-
let settleRunning = false; // 防止并发 settle
|
|
30
|
-
/**
|
|
31
|
-
* 获取本地日期字符串 (YYYY-MM-DD)
|
|
32
|
-
* 使用本地时区而非 UTC,避免跨日期问题
|
|
33
|
-
*/
|
|
34
|
-
function getLocalDateString() {
|
|
35
|
-
const now = new Date();
|
|
36
|
-
const year = now.getFullYear();
|
|
37
|
-
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
38
|
-
const day = String(now.getDate()).padStart(2, "0");
|
|
39
|
-
return `${year}-${month}-${day}`;
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* 创建通用 LLM 调用函数(Anthropic Messages 格式)
|
|
43
|
-
* 供 settle 流程使用,复用 condense 的 API 配置
|
|
44
|
-
*/
|
|
45
|
-
function makeLlmCall(config) {
|
|
46
|
-
const apiKey = config.condense.apiKey || config.embedding.apiKey;
|
|
47
|
-
const baseUrl = config.condense.baseUrl || "https://api.minimaxi.com/anthropic";
|
|
48
|
-
const model = config.condense.model || config.settleModel || "MiniMax-M2.7-highspeed";
|
|
49
|
-
if (!apiKey)
|
|
50
|
-
return undefined;
|
|
51
|
-
return async (prompt, systemPrompt) => {
|
|
52
|
-
const controller = new AbortController();
|
|
53
|
-
const timeoutId = setTimeout(() => controller.abort(), 60_000); // settle 允许 60s
|
|
54
|
-
try {
|
|
55
|
-
const response = await fetch(`${baseUrl}/v1/messages`, {
|
|
56
|
-
method: "POST",
|
|
57
|
-
headers: {
|
|
58
|
-
"Content-Type": "application/json",
|
|
59
|
-
"x-api-key": apiKey,
|
|
60
|
-
"anthropic-version": "2023-06-01",
|
|
61
|
-
},
|
|
62
|
-
body: JSON.stringify({
|
|
63
|
-
model,
|
|
64
|
-
system: systemPrompt || "",
|
|
65
|
-
messages: [{ role: "user", content: prompt }],
|
|
66
|
-
max_tokens: 4096,
|
|
67
|
-
temperature: 0.3,
|
|
68
|
-
}),
|
|
69
|
-
signal: controller.signal,
|
|
70
|
-
});
|
|
71
|
-
clearTimeout(timeoutId);
|
|
72
|
-
if (!response.ok) {
|
|
73
|
-
throw new Error(`LLM API error: ${response.status}`);
|
|
74
|
-
}
|
|
75
|
-
const data = await response.json();
|
|
76
|
-
const textBlock = data.content?.find(b => b.type === "text");
|
|
77
|
-
return textBlock?.text?.trim() || "[]";
|
|
78
|
-
}
|
|
79
|
-
catch (err) {
|
|
80
|
-
clearTimeout(timeoutId);
|
|
81
|
-
throw err;
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* 获取需要 settle 的日期列表
|
|
87
|
-
* 扫描 short-term 目录,找出所有比今天早且未 settle 过的日期
|
|
88
|
-
*/
|
|
89
|
-
async function getUnsettledDates(workspaceDir) {
|
|
90
|
-
const settleStateFile = path.join(workspaceDir, "memory-engram", ".last-settle");
|
|
91
|
-
const shortTermDir = path.join(workspaceDir, "memory-engram", "short-term");
|
|
92
|
-
const today = getLocalDateString();
|
|
93
|
-
// 读取上次 settle 日期
|
|
94
|
-
let lastSettleDate = "";
|
|
95
|
-
try {
|
|
96
|
-
lastSettleDate = (await fs.readFile(settleStateFile, "utf-8")).trim();
|
|
97
|
-
logger.info(`[engram:auto-settle] Last settle date: "${lastSettleDate}"`);
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
// 没有记录,视为从未 settle 过
|
|
101
|
-
logger.info(`[engram:auto-settle] No .last-settle file found, first time settle check`);
|
|
102
|
-
}
|
|
103
|
-
// 扫描 short-term 目录
|
|
104
|
-
try {
|
|
105
|
-
const files = await fs.readdir(shortTermDir);
|
|
106
|
-
const mdFiles = files.filter(f => f.endsWith(".md"));
|
|
107
|
-
const dates = mdFiles
|
|
108
|
-
.map(f => f.replace(".md", ""))
|
|
109
|
-
.filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d))
|
|
110
|
-
.filter(d => d < today) // 只处理前几天的,今天的还在写入中
|
|
111
|
-
.filter(d => d > lastSettleDate) // 只处理上次 settle 之后的
|
|
112
|
-
.sort();
|
|
113
|
-
logger.info(`[engram:auto-settle] Scanned short-term: ${mdFiles.length} md files, ${dates.length} unsettled (today=${today}, lastSettle="${lastSettleDate}")`);
|
|
114
|
-
return dates;
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
logger.info(`[engram:auto-settle] short-term dir not found or empty`);
|
|
118
|
-
return [];
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* 标记 settle 完成日期
|
|
123
|
-
*/
|
|
124
|
-
async function markSettled(workspaceDir, date) {
|
|
125
|
-
const settleStateFile = path.join(workspaceDir, "memory-engram", ".last-settle");
|
|
126
|
-
await fs.mkdir(path.dirname(settleStateFile), { recursive: true });
|
|
127
|
-
await fs.writeFile(settleStateFile, date, "utf-8");
|
|
128
|
-
logger.info(`[engram:auto-settle] Marked settled: ${date} (wrote to ${settleStateFile})`);
|
|
129
|
-
}
|
|
130
|
-
// ---- 对话精简 Prompt(BM25 友好版)----
|
|
131
|
-
const CONDENSE_SYSTEM_PROMPT = `你是一个对话记忆整理助手。请将原始对话精简提炼,输出格式需要同时满足:人类可读 + 关键词检索友好。
|
|
132
|
-
|
|
133
|
-
## 核心原则
|
|
134
|
-
**每个独立事件/话题必须单独输出一行**,不要过度合并!宁可多输出几行,也不要丢失信息。
|
|
135
|
-
|
|
136
|
-
## 输出格式规则
|
|
137
|
-
每条记录一行,格式:
|
|
138
|
-
\`[时段] 主题词 | 关键实体 | 一句话摘要\`
|
|
139
|
-
|
|
140
|
-
### 字段说明
|
|
141
|
-
1. **[时段]**:从对话中提取的时间标签,如 [03-14 晚上]、[03-14 凌晨](保留原样,用于检索"昨晚"等)
|
|
142
|
-
2. **主题词**:2-4个核心动词/名词,空格分隔(如:备份 iCloud 同步)
|
|
143
|
-
3. **关键实体**:涉及的具体名称/数字(如:1.9GB、musetalk、300505.SZ)
|
|
144
|
-
4. **一句话摘要**:完整描述事件(20字以内)
|
|
145
|
-
|
|
146
|
-
## 处理规则
|
|
147
|
-
1. **每个事件单独一行**:不同话题必须分开,只有完全相同主题的连续对话才能合并
|
|
148
|
-
2. **保留所有关键词**:股票代码、文件名、数字、路径、分支名、PR号等必须保留
|
|
149
|
-
3. **保留时段标签**:每条记录必须带上 [MM-DD 时段] 前缀
|
|
150
|
-
4. **过滤纯噪音**:只删除"好的"、"谢谢"这类纯确认词,有实质信息的都要保留
|
|
151
|
-
5. **技术操作必须保留**:git操作、文件修改、代码审核、PR、安装软件等都是重要事件
|
|
152
|
-
6. **行为/状态变化必须保留**:用户的行动(去睡觉、出门、到家、吃饭)、状态变化(累了、生病、开心)、意图声明(准备做XX、打算XX)、问候(早上好、晚安)不是噪音,必须作为独立行输出,主题词使用"作息"/"状态"/"出行"等分类
|
|
153
|
-
|
|
154
|
-
## 示例
|
|
155
|
-
输入:
|
|
156
|
-
Scott [03-13 下午]: 帮我看下目前模拟盘的持仓
|
|
157
|
-
Shadow [03-13 下午]: 📈 ScottK线分析账户 持仓:06181 200股 成本价675.0 当前价650.0 亏损5000 -3.7%
|
|
158
|
-
Scott [03-13 下午]: 话说你的头像我可以在identify里面修改,那么我的头像能自定义吗?
|
|
159
|
-
Shadow [03-13 下午]: 看了代码,目前不支持自定义用户头像,在 grouped-render.ts 里硬编码为默认SVG图标
|
|
160
|
-
Scott [03-13 下午]: 帮我拉新的分支,我需要解决context-notice__icon没有写宽度的bug
|
|
161
|
-
Shadow [03-13 下午]: 已创建分支 scotthuang/fix/dashboard-context-notice-icon-width 并提交修复
|
|
162
|
-
|
|
163
|
-
输出:
|
|
164
|
-
[03-13 下午] 模拟盘 持仓查询 | 06181 200股 675.0 650.0 -5000 -3.7% | 查看ScottK线账户持仓,小幅亏损
|
|
165
|
-
[03-13 下午] OpenClaw 用户头像 自定义 | grouped-render.ts chat-avatar | 查看源码确认不支持用户自定义头像
|
|
166
|
-
[03-13 下午] OpenClaw 修复 分支创建 | context-notice__icon width | 创建分支修复图标宽度bug
|
|
167
|
-
|
|
168
|
-
只输出整理后的内容,不要解释。`;
|
|
169
|
-
/**
|
|
170
|
-
* 将对话内容按消息组分段
|
|
171
|
-
* 每段大约 10-15 条消息,确保不超过指定字符数
|
|
172
|
-
*/
|
|
173
|
-
function splitIntoChunks(content, maxCharsPerChunk = 5000) {
|
|
174
|
-
const lines = content.split("\n");
|
|
175
|
-
const chunks = [];
|
|
176
|
-
let currentChunk = [];
|
|
177
|
-
let currentLength = 0;
|
|
178
|
-
for (const line of lines) {
|
|
179
|
-
// 如果当前行是新消息的开始(以 Scott 或 Shadow 开头)
|
|
180
|
-
const isNewMessage = /^(Scott|Shadow)\s+\[/.test(line);
|
|
181
|
-
// 如果加上这行会超出限制,且当前已有内容,则先保存当前段
|
|
182
|
-
if (currentLength + line.length > maxCharsPerChunk && currentChunk.length > 0 && isNewMessage) {
|
|
183
|
-
chunks.push(currentChunk.join("\n"));
|
|
184
|
-
currentChunk = [];
|
|
185
|
-
currentLength = 0;
|
|
186
|
-
}
|
|
187
|
-
currentChunk.push(line);
|
|
188
|
-
currentLength += line.length + 1; // +1 for newline
|
|
189
|
-
}
|
|
190
|
-
// 保存最后一段
|
|
191
|
-
if (currentChunk.length > 0) {
|
|
192
|
-
chunks.push(currentChunk.join("\n"));
|
|
193
|
-
}
|
|
194
|
-
return chunks;
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* 调用 LLM 精简单个段落
|
|
198
|
-
*/
|
|
199
|
-
async function condenseChunk(content, apiKey, baseUrl, model) {
|
|
200
|
-
// 添加 30 秒超时,避免无限等待导致钩子卡住
|
|
201
|
-
const controller = new AbortController();
|
|
202
|
-
const timeoutId = setTimeout(() => controller.abort(), 30_000);
|
|
203
|
-
try {
|
|
204
|
-
const response = await fetch(`${baseUrl}/v1/messages`, {
|
|
205
|
-
method: "POST",
|
|
206
|
-
headers: {
|
|
207
|
-
"Content-Type": "application/json",
|
|
208
|
-
"x-api-key": apiKey,
|
|
209
|
-
"anthropic-version": "2023-06-01",
|
|
210
|
-
},
|
|
211
|
-
body: JSON.stringify({
|
|
212
|
-
model,
|
|
213
|
-
system: CONDENSE_SYSTEM_PROMPT,
|
|
214
|
-
messages: [
|
|
215
|
-
{ role: "user", content },
|
|
216
|
-
],
|
|
217
|
-
max_tokens: 4096,
|
|
218
|
-
temperature: 0.3,
|
|
219
|
-
}),
|
|
220
|
-
signal: controller.signal,
|
|
221
|
-
});
|
|
222
|
-
clearTimeout(timeoutId);
|
|
223
|
-
if (!response.ok) {
|
|
224
|
-
logger.error(`[engram] Condense chunk API error: ${response.status}`);
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
|
-
const data = await response.json();
|
|
228
|
-
// Anthropic Messages 格式:content 是数组,找 type=text 的块
|
|
229
|
-
const textBlock = data.content?.find(b => b.type === "text");
|
|
230
|
-
return textBlock?.text?.trim() || null;
|
|
231
|
-
}
|
|
232
|
-
catch (err) {
|
|
233
|
-
clearTimeout(timeoutId);
|
|
234
|
-
if (err instanceof Error && err.name === "AbortError") {
|
|
235
|
-
logger.error(`[engram] Condense chunk timeout after 30s`);
|
|
236
|
-
}
|
|
237
|
-
else {
|
|
238
|
-
logger.error(`[engram] Condense chunk failed: ${err}`);
|
|
239
|
-
}
|
|
240
|
-
return null;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
/**
|
|
244
|
-
* 调用字节 LLM 接口精简对话(Coding Plan)
|
|
245
|
-
* 支持分段处理长文本,避免信息丢失
|
|
246
|
-
*/
|
|
247
|
-
async function condenseSessionContent(content, config) {
|
|
248
|
-
// 优先使用 condense 配置,其次使用 embedding 配置,最后使用环境变量
|
|
249
|
-
const apiKey = config.condense.apiKey || config.embedding.apiKey || process.env.ARK_API_KEY || process.env.VOLCENGINE_API_KEY;
|
|
250
|
-
const baseUrl = config.condense.baseUrl || process.env.ARK_BASE_URL || "https://api.minimaxi.com/anthropic";
|
|
251
|
-
const model = config.condense.model || config.settleModel || "MiniMax-M2.7-highspeed";
|
|
252
|
-
if (!apiKey) {
|
|
253
|
-
logger.info("[engram] No API key for condense, skipping LLM condensation");
|
|
254
|
-
return null;
|
|
255
|
-
}
|
|
256
|
-
// 如果内容较短(<5000字符),直接处理
|
|
257
|
-
if (content.length < 5000) {
|
|
258
|
-
logger.info(`[engram] Content short (${content.length} chars), processing directly`);
|
|
259
|
-
return condenseChunk(content, apiKey, baseUrl, model);
|
|
260
|
-
}
|
|
261
|
-
// 长文本分段处理
|
|
262
|
-
const chunks = splitIntoChunks(content, 5000);
|
|
263
|
-
logger.info(`[engram] Content long (${content.length} chars), splitting into ${chunks.length} chunks`);
|
|
264
|
-
const results = [];
|
|
265
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
266
|
-
logger.info(`[engram] Processing chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)`);
|
|
267
|
-
const result = await condenseChunk(chunks[i], apiKey, baseUrl, model);
|
|
268
|
-
if (result) {
|
|
269
|
-
results.push(result);
|
|
270
|
-
logger.info(`[engram] Chunk ${i + 1} condensed: ${result.split("\n").length} lines`);
|
|
271
|
-
}
|
|
272
|
-
// 避免请求过快
|
|
273
|
-
if (i < chunks.length - 1) {
|
|
274
|
-
await new Promise(resolve => setTimeout(resolve, 300));
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
if (results.length === 0) {
|
|
278
|
-
logger.info("[engram] All chunks failed");
|
|
279
|
-
return null;
|
|
280
|
-
}
|
|
281
|
-
const combined = results.join("\n");
|
|
282
|
-
// 后处理去重
|
|
283
|
-
const deduplicated = deduplicateCondensedLines(combined);
|
|
284
|
-
logger.info(`[engram] Condensed ${content.length} chars → ${combined.length} chars → ${deduplicated.length} chars (dedup), ${deduplicated.split("\n").length} lines`);
|
|
285
|
-
return deduplicated;
|
|
286
|
-
}
|
|
287
|
-
/**
|
|
288
|
-
* 后处理去重:移除精简结果中的重复/相似行
|
|
289
|
-
* 使用主题词重叠度判断相似性
|
|
290
|
-
*/
|
|
291
|
-
function deduplicateCondensedLines(content) {
|
|
292
|
-
const lines = content.split("\n").filter(line => line.trim());
|
|
293
|
-
if (lines.length <= 1)
|
|
294
|
-
return content;
|
|
295
|
-
const result = [];
|
|
296
|
-
const seenTopics = new Set();
|
|
297
|
-
for (const line of lines) {
|
|
298
|
-
// 解析格式: [时段] 主题词 | 关键实体 | 摘要
|
|
299
|
-
const match = line.match(/^\[([^\]]+)\]\s*([^|]+)\|/);
|
|
300
|
-
if (!match) {
|
|
301
|
-
result.push(line);
|
|
302
|
-
continue;
|
|
303
|
-
}
|
|
304
|
-
const timePeriod = match[1].trim(); // 如 "03-17 晚上"
|
|
305
|
-
const topicWords = match[2].trim().split(/\s+/).filter(w => w.length > 0);
|
|
306
|
-
// 生成主题指纹:时段 + 排序后的主题词
|
|
307
|
-
const topicKey = `${timePeriod}:${topicWords.sort().join(",")}`;
|
|
308
|
-
// 检查是否与已有主题相似(主题词重叠度 > 60%)
|
|
309
|
-
let isDuplicate = false;
|
|
310
|
-
for (const seen of seenTopics) {
|
|
311
|
-
const [seenPeriod, seenWordsStr] = seen.split(":");
|
|
312
|
-
// 时段不同,不算重复
|
|
313
|
-
if (seenPeriod !== timePeriod)
|
|
314
|
-
continue;
|
|
315
|
-
const seenWords = seenWordsStr ? seenWordsStr.split(",") : [];
|
|
316
|
-
const overlap = topicWords.filter(w => seenWords.includes(w)).length;
|
|
317
|
-
const overlapRatio = overlap / Math.max(topicWords.length, seenWords.length, 1);
|
|
318
|
-
if (overlapRatio > 0.6) {
|
|
319
|
-
isDuplicate = true;
|
|
320
|
-
logger.info(`[engram] Dedup: skipping "${line.slice(0, 50)}..." (similar to existing)`);
|
|
321
|
-
break;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
if (!isDuplicate) {
|
|
325
|
-
result.push(line);
|
|
326
|
-
seenTopics.add(topicKey);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
logger.info(`[engram] Dedup: ${lines.length} lines → ${result.length} lines`);
|
|
330
|
-
return result.join("\n");
|
|
331
|
-
}
|
|
332
|
-
export default function register(api) {
|
|
333
|
-
const rawConfig = api.pluginConfig || {};
|
|
334
|
-
const workspaceDir = api.workspaceDir || process.env.HOME + "/.openclaw/workspace";
|
|
335
|
-
// ---- 使用单例模式初始化(只在第一次调用时创建) ----
|
|
336
|
-
if (!singletonConfig) {
|
|
337
|
-
singletonConfig = parseConfig(rawConfig);
|
|
338
|
-
singletonWorkspaceDir = workspaceDir;
|
|
339
|
-
// 初始化日志系统:传入 logDir 则按天写文件,否则仅 console 输出
|
|
340
|
-
logger.init(singletonConfig.logDir);
|
|
341
|
-
logger.info(`[engram] ========== Engram v${PKG_VERSION} loaded ==========`);
|
|
342
|
-
logger.info(`[engram] Initializing singletons (first register call)`);
|
|
343
|
-
}
|
|
344
|
-
const config = singletonConfig;
|
|
345
|
-
if (!singletonRecallEngine) {
|
|
346
|
-
singletonRecallEngine = new RecallEngine(workspaceDir, config);
|
|
347
|
-
}
|
|
348
|
-
const recallEngine = singletonRecallEngine;
|
|
349
|
-
if (!singletonProfileManager) {
|
|
350
|
-
singletonProfileManager = new ProfileManager(workspaceDir);
|
|
351
|
-
}
|
|
352
|
-
const profileManager = singletonProfileManager;
|
|
353
|
-
// 初始化向量存储(直接连接 LanceDB)
|
|
354
|
-
const lancedbDir = config.lancedbDir || path.join(workspaceDir, "memory-engram", "lancedb");
|
|
355
|
-
if (!singletonVectorStore && config.embedding.apiKey && config.embedding.baseUrl) {
|
|
356
|
-
singletonVectorStore = new VectorStore({
|
|
357
|
-
dbDir: lancedbDir,
|
|
358
|
-
embedding: config.embedding,
|
|
359
|
-
tableName: "memories",
|
|
360
|
-
halfLifeDays: config.halfLifeDays,
|
|
361
|
-
});
|
|
362
|
-
logger.info(`[engram] VectorStore singleton created`);
|
|
363
|
-
}
|
|
364
|
-
const vectorStore = singletonVectorStore;
|
|
365
|
-
// 注入向量搜索(只在第一次时注入)
|
|
366
|
-
if (vectorStore && !recallEngine.hasVectorSearch()) {
|
|
367
|
-
recallEngine.setVectorSearch(async (query, limit) => {
|
|
368
|
-
if (!vectorStore)
|
|
369
|
-
return [];
|
|
370
|
-
return vectorStore.search(query, limit);
|
|
371
|
-
});
|
|
372
|
-
// 注入记忆强化回调
|
|
373
|
-
recallEngine.setReinforceCallback(async (ids) => {
|
|
374
|
-
if (!vectorStore)
|
|
375
|
-
return;
|
|
376
|
-
await vectorStore.reinforce(ids);
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
if (!vectorStore && !config.embedding.apiKey) {
|
|
380
|
-
logger.info("[engram] No embedding config, vector search disabled");
|
|
381
|
-
}
|
|
382
|
-
// 初始化图片存储(始终启用,不依赖额外配置)
|
|
383
|
-
if (!singletonImageStore) {
|
|
384
|
-
singletonImageStore = new ImageStore(workspaceDir);
|
|
385
|
-
logger.info(`[engram] ImageStore singleton created`);
|
|
386
|
-
}
|
|
387
|
-
const imageStore = singletonImageStore;
|
|
388
|
-
// ---- Agent Tools ----
|
|
389
|
-
api.registerTool({
|
|
390
|
-
name: "memory_system_search",
|
|
391
|
-
label: "Memory Search",
|
|
392
|
-
description: "搜索记忆(短期+长期双路召回+画像增强)。用于查找用户偏好、历史事件、之前讨论的话题。",
|
|
393
|
-
parameters: {
|
|
394
|
-
type: "object",
|
|
395
|
-
properties: {
|
|
396
|
-
query: { type: "string", description: "搜索查询" },
|
|
397
|
-
limit: { type: "number", description: "最大返回数(默认使用配置值)" },
|
|
398
|
-
},
|
|
399
|
-
required: ["query"],
|
|
400
|
-
},
|
|
401
|
-
async execute(_toolCallId, params) {
|
|
402
|
-
const { query, limit } = params;
|
|
403
|
-
const { results, memoryContext } = await recallEngine.recall(query);
|
|
404
|
-
return {
|
|
405
|
-
content: [{ type: "text", text: results || "No relevant memories found." }],
|
|
406
|
-
details: { count: results.split("\n").length },
|
|
407
|
-
};
|
|
408
|
-
},
|
|
409
|
-
}, { name: "memory_system_search" });
|
|
410
|
-
api.registerTool({
|
|
411
|
-
name: "memory_system_profile",
|
|
412
|
-
label: "User Profile",
|
|
413
|
-
description: "查看或更新用户语义画像。",
|
|
414
|
-
parameters: {
|
|
415
|
-
type: "object",
|
|
416
|
-
properties: {
|
|
417
|
-
action: { type: "string", enum: ["get", "update"], description: "操作类型" },
|
|
418
|
-
dimension: { type: "string", description: "标签维度(update 时使用)" },
|
|
419
|
-
value: { type: "string", description: "标签值(update 时使用)" },
|
|
420
|
-
},
|
|
421
|
-
required: ["action"],
|
|
422
|
-
},
|
|
423
|
-
async execute(_toolCallId, params) {
|
|
424
|
-
const { action, dimension, value } = params;
|
|
425
|
-
const profile = await profileManager.load();
|
|
426
|
-
if (action === "get") {
|
|
427
|
-
const context = profileManager.getRecallContext(profile);
|
|
428
|
-
const fullTags = Object.entries(profile.tags)
|
|
429
|
-
.map(([dim, tags]) => `- **${dim}**: ${tags.map(t => `${t.value}(${(t.confidence * 100).toFixed(0)}%)`).join(", ")}`)
|
|
430
|
-
.join("\n");
|
|
431
|
-
return {
|
|
432
|
-
content: [{
|
|
433
|
-
type: "text",
|
|
434
|
-
text: `${context}\n\n## 完整画像\n${fullTags || "(暂无标签)"}`,
|
|
435
|
-
}],
|
|
436
|
-
};
|
|
437
|
-
}
|
|
438
|
-
if (action === "update" && dimension && value) {
|
|
439
|
-
profileManager.addTag(profile, dimension, value);
|
|
440
|
-
await profileManager.save(profile);
|
|
441
|
-
return {
|
|
442
|
-
content: [{ type: "text", text: `Added tag: [${dimension}] ${value}` }],
|
|
443
|
-
};
|
|
444
|
-
}
|
|
445
|
-
return { content: [{ type: "text", text: "Invalid action or missing params." }] };
|
|
446
|
-
},
|
|
447
|
-
}, { name: "memory_system_profile" });
|
|
448
|
-
api.registerTool({
|
|
449
|
-
name: "memory_system_store",
|
|
450
|
-
label: "Memory Store",
|
|
451
|
-
description: "存储记忆到长期向量层。用于保存重要信息、用户偏好、关键决策等需要持久记住的内容。",
|
|
452
|
-
parameters: {
|
|
453
|
-
type: "object",
|
|
454
|
-
properties: {
|
|
455
|
-
text: { type: "string", description: "要存储的记忆文本" },
|
|
456
|
-
category: { type: "string", description: "分类(偏好、技术、家庭、健康、工作、决策等)" },
|
|
457
|
-
},
|
|
458
|
-
required: ["text"],
|
|
459
|
-
},
|
|
460
|
-
async execute(_toolCallId, params) {
|
|
461
|
-
const text = params.text;
|
|
462
|
-
const category = params.category || "通用";
|
|
463
|
-
if (!text || text.trim().length === 0) {
|
|
464
|
-
return { content: [{ type: "text", text: "Missing text parameter." }] };
|
|
465
|
-
}
|
|
466
|
-
try {
|
|
467
|
-
if (vectorStore) {
|
|
468
|
-
// Agent 主动存储的记忆视为高重要度
|
|
469
|
-
const result = await vectorStore.store(text.trim(), category, 0.7);
|
|
470
|
-
if (result === "duplicate") {
|
|
471
|
-
return { content: [{ type: "text", text: "⚠️ Similar memory already exists, skipped." }] };
|
|
472
|
-
}
|
|
473
|
-
return { content: [{ type: "text", text: "✅ Memory stored to vector DB." }] };
|
|
474
|
-
}
|
|
475
|
-
return { content: [{ type: "text", text: "Vector store not configured." }] };
|
|
476
|
-
}
|
|
477
|
-
catch (e) {
|
|
478
|
-
return { content: [{ type: "text", text: `Failed to store: ${e.message}` }] };
|
|
479
|
-
}
|
|
480
|
-
},
|
|
481
|
-
}, { name: "memory_system_store" });
|
|
482
|
-
api.registerTool({
|
|
483
|
-
name: "save_image",
|
|
484
|
-
label: "Save Image",
|
|
485
|
-
description: `将图片保存到记忆库。使用前必须先用 image 工具理解图片内容,获得描述后再调用本工具存储。
|
|
486
|
-
|
|
487
|
-
工作流:
|
|
488
|
-
1. 用户要求保存/记住某张图片时,先调用 image 工具查看图片内容
|
|
489
|
-
2. 根据 image 工具返回的描述,提取场景、人物、实体等信息
|
|
490
|
-
3. 调用本工具,传入图片路径、描述文本和实体关键词
|
|
491
|
-
|
|
492
|
-
判断标准 — 仅在以下情况使用:
|
|
493
|
-
- 用户明确说"记住/保存/存一下这张图"
|
|
494
|
-
- 图片包含长期记忆价值(合照、名片、证件、重要截图等)
|
|
495
|
-
|
|
496
|
-
不要使用的情况:
|
|
497
|
-
- 用户只是让你"看看这张图上写了什么"(工作类,用完即弃)
|
|
498
|
-
- 临时分析、提取文字、翻译图片内容等一次性任务`,
|
|
499
|
-
parameters: {
|
|
500
|
-
type: "object",
|
|
501
|
-
properties: {
|
|
502
|
-
imagePath: { type: "string", description: "图片文件的绝对路径" },
|
|
503
|
-
description: { type: "string", description: "图片内容的文字描述(场景、人物、实体等)" },
|
|
504
|
-
entities: {
|
|
505
|
-
type: "array",
|
|
506
|
-
items: { type: "string" },
|
|
507
|
-
description: "图片中的关键实体(人名、地点、物品、品牌等)",
|
|
508
|
-
},
|
|
509
|
-
source: { type: "string", description: "来源渠道(如 wechat、screenshot 等),默认 unknown" },
|
|
510
|
-
},
|
|
511
|
-
required: ["imagePath", "description"],
|
|
512
|
-
},
|
|
513
|
-
async execute(_toolCallId, params) {
|
|
514
|
-
const { imagePath, description, entities, source } = params;
|
|
515
|
-
if (!imagePath || !description) {
|
|
516
|
-
return { content: [{ type: "text", text: "Missing imagePath or description." }] };
|
|
517
|
-
}
|
|
518
|
-
try {
|
|
519
|
-
// 1. 存储图片文件
|
|
520
|
-
const result = await imageStore.storeImage(imagePath, { source: source || "unknown" });
|
|
521
|
-
if (result.isDuplicate) {
|
|
522
|
-
// 即使是重复图片,也更新描述(可能之前没有描述)
|
|
523
|
-
await imageStore.updateMetadata(result.relativePath, {
|
|
524
|
-
description: description.slice(0, 2000),
|
|
525
|
-
entities: entities || [],
|
|
526
|
-
});
|
|
527
|
-
return {
|
|
528
|
-
content: [{ type: "text", text: `图片已存在(重复),已更新描述。路径: ${result.relativePath}` }],
|
|
529
|
-
details: { relativePath: result.relativePath, isDuplicate: true },
|
|
530
|
-
};
|
|
531
|
-
}
|
|
532
|
-
// 2. 写入描述和实体
|
|
533
|
-
await imageStore.updateMetadata(result.relativePath, {
|
|
534
|
-
description: description.slice(0, 2000),
|
|
535
|
-
entities: entities || [],
|
|
536
|
-
});
|
|
537
|
-
logger.info(`[engram] save_image: stored ${result.relativePath}, description="${description.slice(0, 80)}..."`);
|
|
538
|
-
return {
|
|
539
|
-
content: [{ type: "text", text: `✅ 图片已保存到记忆库。\n路径: ${result.relativePath}\n描述: ${description.slice(0, 100)}${description.length > 100 ? "..." : ""}` }],
|
|
540
|
-
details: { relativePath: result.relativePath, isDuplicate: false },
|
|
541
|
-
};
|
|
542
|
-
}
|
|
543
|
-
catch (e) {
|
|
544
|
-
logger.error(`[engram] save_image failed: ${e}`);
|
|
545
|
-
return { content: [{ type: "text", text: `保存失败: ${e.message}` }] };
|
|
546
|
-
}
|
|
547
|
-
},
|
|
548
|
-
}, { name: "save_image" });
|
|
549
|
-
api.registerTool({
|
|
550
|
-
name: "search_images",
|
|
551
|
-
label: "Image Search",
|
|
552
|
-
description: "搜索用户的图片记忆。当用户明确要求查找、回忆某张图片时使用。输入搜索关键词,返回匹配的图片列表及描述。",
|
|
553
|
-
parameters: {
|
|
554
|
-
type: "object",
|
|
555
|
-
properties: {
|
|
556
|
-
query: { type: "string", description: "搜索关键词(如'微信合照'、'咖啡厅'、'团队合影')" },
|
|
557
|
-
limit: { type: "number", description: "返回数量上限,默认5" },
|
|
558
|
-
},
|
|
559
|
-
required: ["query"],
|
|
560
|
-
},
|
|
561
|
-
async execute(_toolCallId, params) {
|
|
562
|
-
const { query, limit } = params;
|
|
563
|
-
if (!query || query.trim().length === 0) {
|
|
564
|
-
return { content: [{ type: "text", text: "Missing query parameter." }] };
|
|
565
|
-
}
|
|
566
|
-
try {
|
|
567
|
-
const results = await imageStore.searchByText(query.trim(), limit || 5);
|
|
568
|
-
if (results.length === 0) {
|
|
569
|
-
return { content: [{ type: "text", text: "未找到匹配的图片。" }] };
|
|
570
|
-
}
|
|
571
|
-
const formatted = results.map((r, i) => `${i + 1}. [${r.date}] ${r.description}\n 关键词: ${r.entities.join(", ")}\n 路径: ${r.relativePath}\n 来源: ${r.source} | 相关度: ${r.score}`).join("\n\n");
|
|
572
|
-
return {
|
|
573
|
-
content: [{ type: "text", text: `找到 ${results.length} 张匹配图片:\n\n${formatted}` }],
|
|
574
|
-
details: { count: results.length, results },
|
|
575
|
-
};
|
|
576
|
-
}
|
|
577
|
-
catch (e) {
|
|
578
|
-
logger.error(`[engram] search_images failed: ${e}`);
|
|
579
|
-
return { content: [{ type: "text", text: `搜索失败: ${e.message}` }] };
|
|
580
|
-
}
|
|
581
|
-
},
|
|
582
|
-
}, { name: "search_images" });
|
|
583
|
-
// ---- Auto-Recall: before_prompt_build ----
|
|
584
|
-
// 黑名单:这些系统引导消息不需要记忆召回
|
|
585
|
-
// 黑名单:这些系统/自动化消息不需要记忆召回
|
|
586
|
-
const RECALL_BLACKLIST_PATTERNS = [
|
|
587
|
-
/^A new session was started via \/new or \/reset/i,
|
|
588
|
-
/^Run your Session Startup sequence/i,
|
|
589
|
-
/^Session Startup sequence/i,
|
|
590
|
-
/^Read HEARTBEAT\.md/i, // 心跳任务
|
|
591
|
-
/^Based on this conversation, generate a short/i, // 文件名生成
|
|
592
|
-
];
|
|
593
|
-
/**
|
|
594
|
-
* 检查是否应该跳过记忆召回
|
|
595
|
-
*/
|
|
596
|
-
function shouldSkipRecall(prompt) {
|
|
597
|
-
for (const pattern of RECALL_BLACKLIST_PATTERNS) {
|
|
598
|
-
if (pattern.test(prompt)) {
|
|
599
|
-
return true;
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
return false;
|
|
603
|
-
}
|
|
604
|
-
api.on("before_agent_start", async (event) => {
|
|
605
|
-
const prompt = event.prompt || "";
|
|
606
|
-
logger.info(`[engram] before_agent_start triggered, prompt length=${prompt.length}, first200="${prompt.slice(0, 200)}"`);
|
|
607
|
-
if (prompt.length < 5)
|
|
608
|
-
return;
|
|
609
|
-
// 检查黑名单:系统引导消息不需要召回
|
|
610
|
-
if (shouldSkipRecall(prompt)) {
|
|
611
|
-
logger.info(`[engram] Skipping recall for system startup prompt`);
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
614
|
-
// ---- Auto-Settle: 检查前几天是否有未沉淀的短期记忆 ----
|
|
615
|
-
// fire-and-forget,不阻塞当前对话
|
|
616
|
-
if (!settleRunning) {
|
|
617
|
-
(async () => {
|
|
618
|
-
try {
|
|
619
|
-
const unsettledDates = await getUnsettledDates(workspaceDir);
|
|
620
|
-
if (unsettledDates.length === 0)
|
|
621
|
-
return;
|
|
622
|
-
const llmCall = makeLlmCall(config);
|
|
623
|
-
if (!llmCall) {
|
|
624
|
-
logger.info(`[engram:auto-settle] No LLM config, skipping auto-settle`);
|
|
625
|
-
return;
|
|
626
|
-
}
|
|
627
|
-
settleRunning = true;
|
|
628
|
-
logger.info(`[engram:auto-settle] Found ${unsettledDates.length} unsettled date(s): [${unsettledDates.join(", ")}]`);
|
|
629
|
-
for (const date of unsettledDates) {
|
|
630
|
-
logger.info(`[engram:auto-settle] Running settlement for ${date}...`);
|
|
631
|
-
try {
|
|
632
|
-
const results = await runSettlement({
|
|
633
|
-
workspaceDir,
|
|
634
|
-
config,
|
|
635
|
-
llmCall,
|
|
636
|
-
vectorStore: vectorStore
|
|
637
|
-
? (text, category, importance) => vectorStore.store(text, category, importance)
|
|
638
|
-
: undefined,
|
|
639
|
-
vectorPrune: vectorStore
|
|
640
|
-
? (threshold, maxPrune) => vectorStore.prune(threshold, maxPrune)
|
|
641
|
-
: undefined,
|
|
642
|
-
}, date);
|
|
643
|
-
logger.info(`[engram:auto-settle] Settlement for ${date} done: ${results.join(" | ")}`);
|
|
644
|
-
await markSettled(workspaceDir, date);
|
|
645
|
-
}
|
|
646
|
-
catch (err) {
|
|
647
|
-
logger.error(`[engram:auto-settle] Settlement for ${date} failed: ${err}`);
|
|
648
|
-
// 不 markSettled,下次对话再重试
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
catch (err) {
|
|
653
|
-
logger.error(`[engram:auto-settle] Auto-settle check failed: ${err}`);
|
|
654
|
-
}
|
|
655
|
-
finally {
|
|
656
|
-
settleRunning = false;
|
|
657
|
-
}
|
|
658
|
-
})();
|
|
659
|
-
}
|
|
660
|
-
// ---- 纯图片消息检测:如果没有用户实际文字内容则跳过 recall ----
|
|
661
|
-
const hasMedia = prompt.includes("[media attached:");
|
|
662
|
-
// 去掉 [media attached: ...] 标记、系统图片指令、Conversation info 等元数据
|
|
663
|
-
let userText = prompt;
|
|
664
|
-
// 移除 [media attached: ...](可能没有闭合 ])
|
|
665
|
-
userText = userText.replace(/\[media attached:[^\]]*\]?/gi, "");
|
|
666
|
-
// 移除 "To send an image back..." 系统图片指令段落
|
|
667
|
-
userText = userText.replace(/To send an image back[^]*?Keep caption in the text body\./gi, "");
|
|
668
|
-
// 移除 "Conversation info (untrusted metadata):" 及后续内容
|
|
669
|
-
userText = userText.replace(/Conversation info \(untrusted metadata\):[^]*/gi, "");
|
|
670
|
-
// 移除 "Sender (untrusted metadata):" 及后续内容
|
|
671
|
-
userText = userText.replace(/Sender \(untrusted metadata\):[^]*/gi, "");
|
|
672
|
-
userText = userText.trim();
|
|
673
|
-
if (hasMedia && userText.length < 10) {
|
|
674
|
-
logger.info(`[engram] Image-only message detected (remaining text="${userText.slice(0, 50)}"), skipping recall`);
|
|
675
|
-
return;
|
|
676
|
-
}
|
|
677
|
-
try {
|
|
678
|
-
const cleanPrompt = cleanMessageText(prompt);
|
|
679
|
-
logger.info(`[engram] recall query (cleaned): "${cleanPrompt.slice(0, 100)}"`);
|
|
680
|
-
const { results, memoryContext } = await recallEngine.recall(cleanPrompt);
|
|
681
|
-
logger.info(`[engram] recall result: ${results ? results.split("\n").length : 0} lines, memoryContext length=${memoryContext?.length || 0}`);
|
|
682
|
-
if (!memoryContext)
|
|
683
|
-
return;
|
|
684
|
-
return { prependContext: memoryContext };
|
|
685
|
-
}
|
|
686
|
-
catch (err) {
|
|
687
|
-
logger.error(`[engram] Recall failed: ${err}`);
|
|
688
|
-
}
|
|
689
|
-
}, { priority: 5 });
|
|
690
|
-
// ---- Hooks: command:new / command:reset 即时沉淀 ----
|
|
691
|
-
// 在 /new 或 /reset 时,从上一轮 session 提取对话内容写入 short-term
|
|
692
|
-
// 注意:plugin hook 环境没有 LLM 调用能力,只能做原始消息转存
|
|
693
|
-
// 防重入:记录已处理过的 sessionId,避免重复处理同一个 session
|
|
694
|
-
const processedSessionIds = new Set();
|
|
695
|
-
// 防重入:记录正在处理的内容指纹,避免并发异步任务重复写入
|
|
696
|
-
const processingFingerprints = new Set();
|
|
697
|
-
// 防重入:记录已处理过的 sessionFile 路径(最强的去重)
|
|
698
|
-
const processedSessionFiles = new Set();
|
|
699
|
-
// ★★★ 文件锁:使用文件系统持久化锁状态,避免多实例问题 ★★★
|
|
700
|
-
const COMMAND_NEW_COOLDOWN_MS = 10_000; // 10秒冷却
|
|
701
|
-
/**
|
|
702
|
-
* 检查文件锁是否有效(10秒内)
|
|
703
|
-
* @returns true 如果锁有效(应该跳过),false 如果锁无效或过期(可以处理)
|
|
704
|
-
*/
|
|
705
|
-
async function checkFileLock(lockFile) {
|
|
706
|
-
try {
|
|
707
|
-
const stat = await fs.stat(lockFile);
|
|
708
|
-
const age = Date.now() - stat.mtimeMs;
|
|
709
|
-
if (age < COMMAND_NEW_COOLDOWN_MS) {
|
|
710
|
-
logger.info(`[engram] File lock active (${Math.round((COMMAND_NEW_COOLDOWN_MS - age) / 1000)}s left), skipping`);
|
|
711
|
-
return true; // 锁有效,跳过
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
catch {
|
|
715
|
-
// 文件不存在,锁无效
|
|
716
|
-
}
|
|
717
|
-
return false; // 锁无效或过期,可以处理
|
|
718
|
-
}
|
|
719
|
-
/**
|
|
720
|
-
* 创建文件锁
|
|
721
|
-
*/
|
|
722
|
-
async function createFileLock(lockFile) {
|
|
723
|
-
try {
|
|
724
|
-
await fs.mkdir(path.dirname(lockFile), { recursive: true });
|
|
725
|
-
await fs.writeFile(lockFile, Date.now().toString(), "utf-8");
|
|
726
|
-
}
|
|
727
|
-
catch (err) {
|
|
728
|
-
logger.error(`[engram] Failed to create file lock: ${err}`);
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
/**
|
|
732
|
-
* 清理消息文本:移除注入的 metadata 和 memory 标签
|
|
733
|
-
*/
|
|
734
|
-
function cleanMessageText(raw) {
|
|
735
|
-
let text = raw;
|
|
736
|
-
// 移除 <relevant-memories>...</relevant-memories>
|
|
737
|
-
text = text.replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>/g, "");
|
|
738
|
-
// 移除 Sender (untrusted metadata) blocks
|
|
739
|
-
text = text.replace(/Sender \(untrusted metadata\):\s*```json[\s\S]*?```/g, "");
|
|
740
|
-
// 移除 <inbound-context>...</inbound-context>
|
|
741
|
-
text = text.replace(/<inbound-context>[\s\S]*?<\/inbound-context>/g, "");
|
|
742
|
-
// 移除时间戳行如 [Tue 2026-03-17 04:38 GMT+8]
|
|
743
|
-
text = text.replace(/^\[.*?\]\s*/gm, "");
|
|
744
|
-
// 移除 XML 标签残留
|
|
745
|
-
text = text.replace(/<\/?[^>]+>/g, "");
|
|
746
|
-
return text.trim();
|
|
747
|
-
}
|
|
748
|
-
/**
|
|
749
|
-
* 从 session JSONL 文件提取 user/assistant 消息
|
|
750
|
-
* - 按时间顺序排列
|
|
751
|
-
* - 清理注入的 metadata/memory 标签
|
|
752
|
-
* - 连续相同角色的消息合并
|
|
753
|
-
* - 去掉命令(/开头)和过短的消息
|
|
754
|
-
*/
|
|
755
|
-
/**
|
|
756
|
-
* 从 ISO 时间戳提取本地时间字符串(东八区 GMT+8)
|
|
757
|
-
* 返回格式:MM-DD 时段(凌晨/上午/下午/晚上)
|
|
758
|
-
*
|
|
759
|
-
* 时段划分:
|
|
760
|
-
* - 凌晨: 00:00-06:00
|
|
761
|
-
* - 上午: 06:00-12:00
|
|
762
|
-
* - 下午: 12:00-18:00
|
|
763
|
-
* - 晚上: 18:00-24:00
|
|
764
|
-
*/
|
|
765
|
-
function formatTimestamp(isoTimestamp) {
|
|
766
|
-
try {
|
|
767
|
-
const d = new Date(isoTimestamp);
|
|
768
|
-
if (isNaN(d.getTime()))
|
|
769
|
-
return "";
|
|
770
|
-
// 转换为 GMT+8
|
|
771
|
-
const utc = d.getTime() + d.getTimezoneOffset() * 60000;
|
|
772
|
-
const gmt8 = new Date(utc + 8 * 3600000);
|
|
773
|
-
const month = String(gmt8.getMonth() + 1).padStart(2, "0");
|
|
774
|
-
const day = String(gmt8.getDate()).padStart(2, "0");
|
|
775
|
-
const hour = gmt8.getHours();
|
|
776
|
-
// 时段划分
|
|
777
|
-
let period;
|
|
778
|
-
if (hour < 6) {
|
|
779
|
-
period = "凌晨";
|
|
780
|
-
}
|
|
781
|
-
else if (hour < 12) {
|
|
782
|
-
period = "上午";
|
|
783
|
-
}
|
|
784
|
-
else if (hour < 18) {
|
|
785
|
-
period = "下午";
|
|
786
|
-
}
|
|
787
|
-
else {
|
|
788
|
-
period = "晚上";
|
|
789
|
-
}
|
|
790
|
-
return `${month}-${day} ${period}`;
|
|
791
|
-
}
|
|
792
|
-
catch {
|
|
793
|
-
return "";
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
async function extractMessagesFromSessionFile(sessionFilePath, messageCount = 50) {
|
|
797
|
-
try {
|
|
798
|
-
logger.info(`[engram] extractMessagesFromSessionFile: reading ${sessionFilePath}`);
|
|
799
|
-
const raw = await fs.readFile(sessionFilePath, "utf-8");
|
|
800
|
-
const lines = raw.trim().split("\n");
|
|
801
|
-
const messages = [];
|
|
802
|
-
for (const line of lines) {
|
|
803
|
-
try {
|
|
804
|
-
const entry = JSON.parse(line);
|
|
805
|
-
if (entry.type === "message" && entry.message) {
|
|
806
|
-
const msg = entry.message;
|
|
807
|
-
const role = msg.role;
|
|
808
|
-
if (role !== "user" && role !== "assistant")
|
|
809
|
-
continue;
|
|
810
|
-
let text = "";
|
|
811
|
-
if (Array.isArray(msg.content)) {
|
|
812
|
-
text = msg.content
|
|
813
|
-
.filter((c) => c.type === "text")
|
|
814
|
-
.map((c) => c.text)
|
|
815
|
-
.join("");
|
|
816
|
-
}
|
|
817
|
-
else if (typeof msg.content === "string") {
|
|
818
|
-
text = msg.content;
|
|
819
|
-
}
|
|
820
|
-
if (!text)
|
|
821
|
-
continue;
|
|
822
|
-
text = cleanMessageText(text);
|
|
823
|
-
if (!text)
|
|
824
|
-
continue;
|
|
825
|
-
// 跳过命令和过短/无意义消息
|
|
826
|
-
if (text.startsWith("/"))
|
|
827
|
-
continue;
|
|
828
|
-
if (text.length < 3)
|
|
829
|
-
continue;
|
|
830
|
-
if (text === "HEARTBEAT_OK")
|
|
831
|
-
continue;
|
|
832
|
-
// 获取消息时间戳(优先用 entry.timestamp,其次 msg.timestamp)
|
|
833
|
-
const rawTs = entry.timestamp || (msg.timestamp ? new Date(msg.timestamp).toISOString() : "");
|
|
834
|
-
const ts = formatTimestamp(rawTs);
|
|
835
|
-
// 过滤系统引导消息(/new /reset 触发的启动提示)
|
|
836
|
-
if (text.includes("A new session was started via /new or /reset") ||
|
|
837
|
-
text.includes("Run your Session Startup sequence") ||
|
|
838
|
-
text.includes("read the required files before responding") ||
|
|
839
|
-
text.includes("greet the user in your configured persona")) {
|
|
840
|
-
logger.info(`[engram] Skipping system startup prompt message`);
|
|
841
|
-
continue;
|
|
842
|
-
}
|
|
843
|
-
// 连续同角色消息合并(保留第一条的时间戳)
|
|
844
|
-
if (messages.length > 0 && messages[messages.length - 1].role === role) {
|
|
845
|
-
messages[messages.length - 1].text += "\n" + text;
|
|
846
|
-
}
|
|
847
|
-
else {
|
|
848
|
-
messages.push({ role, text, timestamp: ts });
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
catch {
|
|
853
|
-
// skip malformed lines
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
logger.info(`[engram] extractMessagesFromSessionFile: parsed ${messages.length} messages from ${lines.length} lines`);
|
|
857
|
-
if (messages.length === 0)
|
|
858
|
-
return null;
|
|
859
|
-
const recent = messages.slice(-messageCount);
|
|
860
|
-
const formatted = recent
|
|
861
|
-
.map((m) => {
|
|
862
|
-
const name = m.role === "user" ? "Scott" : "Shadow";
|
|
863
|
-
const timeTag = m.timestamp ? ` [${m.timestamp}]` : "";
|
|
864
|
-
return `${name}${timeTag}: ${m.text}`;
|
|
865
|
-
})
|
|
866
|
-
.join("\n\n");
|
|
867
|
-
return formatted;
|
|
868
|
-
}
|
|
869
|
-
catch (err) {
|
|
870
|
-
logger.error(`[engram] extractMessagesFromSessionFile: failed to read ${sessionFilePath}: ${err}`);
|
|
871
|
-
return null;
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
/**
|
|
875
|
-
* 尝试从主 sessionFile 或 .reset.* 备份文件提取内容
|
|
876
|
-
* 参考 openclaw 内置 session-memory hook 的 fallback 逻辑
|
|
877
|
-
*/
|
|
878
|
-
async function getRecentSessionContentWithResetFallback(sessionFile, messageCount = 20) {
|
|
879
|
-
logger.info(`[engram] getRecentSessionContentWithResetFallback: sessionFile=${sessionFile}`);
|
|
880
|
-
// 先尝试主文件
|
|
881
|
-
const primary = await extractMessagesFromSessionFile(sessionFile, messageCount);
|
|
882
|
-
if (primary) {
|
|
883
|
-
logger.info(`[engram] getRecentSessionContentWithResetFallback: got content from primary file (${primary.length} chars)`);
|
|
884
|
-
return primary;
|
|
885
|
-
}
|
|
886
|
-
// fallback: 查找 .reset.* 后缀的备份文件
|
|
887
|
-
try {
|
|
888
|
-
const dir = path.dirname(sessionFile);
|
|
889
|
-
const base = path.basename(sessionFile);
|
|
890
|
-
const resetPrefix = `${base}.reset.`;
|
|
891
|
-
logger.info(`[engram] getRecentSessionContentWithResetFallback: looking for reset fallback in ${dir}, prefix=${resetPrefix}`);
|
|
892
|
-
const files = await fs.readdir(dir);
|
|
893
|
-
const resetCandidates = files.filter((n) => n.startsWith(resetPrefix)).sort();
|
|
894
|
-
if (resetCandidates.length === 0) {
|
|
895
|
-
logger.info(`[engram] getRecentSessionContentWithResetFallback: no reset fallback files found`);
|
|
896
|
-
return null;
|
|
897
|
-
}
|
|
898
|
-
const latestResetPath = path.join(dir, resetCandidates[resetCandidates.length - 1]);
|
|
899
|
-
logger.info(`[engram] getRecentSessionContentWithResetFallback: trying latest reset file: ${latestResetPath}`);
|
|
900
|
-
const fallback = await extractMessagesFromSessionFile(latestResetPath, messageCount);
|
|
901
|
-
if (fallback) {
|
|
902
|
-
logger.info(`[engram] getRecentSessionContentWithResetFallback: got content from reset fallback (${fallback.length} chars)`);
|
|
903
|
-
}
|
|
904
|
-
return fallback;
|
|
905
|
-
}
|
|
906
|
-
catch (fbErr) {
|
|
907
|
-
logger.error(`[engram] getRecentSessionContentWithResetFallback: reset fallback failed: ${fbErr}`);
|
|
908
|
-
return null;
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
api.registerHook(["command:new", "command:reset"], async (event) => {
|
|
912
|
-
try {
|
|
913
|
-
const date = getLocalDateString();
|
|
914
|
-
const timestamp = new Date().toLocaleTimeString("zh-CN", {
|
|
915
|
-
hour: "2-digit",
|
|
916
|
-
minute: "2-digit",
|
|
917
|
-
});
|
|
918
|
-
const shortTermDir = path.join(workspaceDir, "memory-engram", "short-term");
|
|
919
|
-
await fs.mkdir(shortTermDir, { recursive: true });
|
|
920
|
-
const filePath = path.join(shortTermDir, `${date}.md`);
|
|
921
|
-
logger.info(`[engram] command:${event.action} hook triggered, action=${event.action}, sessionKey=${event.sessionKey}`);
|
|
922
|
-
// ★★★ 文件锁:10秒内只处理一次(跨实例有效) ★★★
|
|
923
|
-
const lockFile = path.join(workspaceDir, "memory-engram", ".command-reset.lock");
|
|
924
|
-
if (await checkFileLock(lockFile)) {
|
|
925
|
-
return; // 锁有效,跳过
|
|
926
|
-
}
|
|
927
|
-
// 立即创建锁,防止并发
|
|
928
|
-
await createFileLock(lockFile);
|
|
929
|
-
logger.info(`[engram] File lock acquired, processing...`);
|
|
930
|
-
// 从 event.context 获取 previousSessionEntry(/new 触发前的旧 session)
|
|
931
|
-
const ctx = event.context || {};
|
|
932
|
-
// 重要:只使用 previousSessionEntry,不要 fallback 到 sessionEntry
|
|
933
|
-
// sessionEntry 是新创建的空 session,不需要处理
|
|
934
|
-
const prevEntry = ctx.previousSessionEntry;
|
|
935
|
-
// 如果没有 previousSessionEntry,说明这是一个全新的 session(没有历史),跳过
|
|
936
|
-
if (!prevEntry) {
|
|
937
|
-
logger.info(`[engram] No previousSessionEntry, this is a fresh session start, skipping`);
|
|
938
|
-
return;
|
|
939
|
-
}
|
|
940
|
-
const sessionFile = prevEntry.sessionFile;
|
|
941
|
-
const sessionId = prevEntry.sessionId;
|
|
942
|
-
// 如果没有 sessionId,也跳过
|
|
943
|
-
if (!sessionId) {
|
|
944
|
-
logger.info(`[engram] No sessionId in previousSessionEntry, skipping`);
|
|
945
|
-
return;
|
|
946
|
-
}
|
|
947
|
-
// ★★★ 最强去重:检查 sessionFile 是否已处理过 ★★★
|
|
948
|
-
if (sessionFile && processedSessionFiles.has(sessionFile)) {
|
|
949
|
-
logger.info(`[engram] SessionFile ${sessionFile} already processed, skipping`);
|
|
950
|
-
return;
|
|
951
|
-
}
|
|
952
|
-
// 防重入:检查是否已经处理过这个 session
|
|
953
|
-
if (processedSessionIds.has(sessionId)) {
|
|
954
|
-
logger.info(`[engram] Session ${sessionId} already processed, skipping`);
|
|
955
|
-
return;
|
|
956
|
-
}
|
|
957
|
-
// ★ 关键修复:立即标记为已处理,防止重复触发
|
|
958
|
-
processedSessionIds.add(sessionId);
|
|
959
|
-
if (sessionFile) {
|
|
960
|
-
processedSessionFiles.add(sessionFile);
|
|
961
|
-
}
|
|
962
|
-
logger.info(`[engram] Marked session ${sessionId} (file: ${sessionFile}) as processing (early lock)`);
|
|
963
|
-
logger.info(`[engram] context dump: previousSessionEntry=${JSON.stringify({
|
|
964
|
-
sessionId: sessionId,
|
|
965
|
-
sessionFile: sessionFile || null,
|
|
966
|
-
contextKeys: Object.keys(ctx),
|
|
967
|
-
})}`);
|
|
968
|
-
let sessionContent = null;
|
|
969
|
-
if (sessionFile) {
|
|
970
|
-
sessionContent = await getRecentSessionContentWithResetFallback(sessionFile);
|
|
971
|
-
}
|
|
972
|
-
else {
|
|
973
|
-
logger.info(`[engram] No sessionFile in previousSessionEntry, cannot extract session content`);
|
|
974
|
-
}
|
|
975
|
-
if (sessionContent) {
|
|
976
|
-
// 防重复:检查文件是否已包含相同内容
|
|
977
|
-
let existing = "";
|
|
978
|
-
try {
|
|
979
|
-
existing = await fs.readFile(filePath, "utf-8");
|
|
980
|
-
}
|
|
981
|
-
catch { }
|
|
982
|
-
// 取 sessionContent 前 100 字符作为指纹
|
|
983
|
-
const fingerprint = sessionContent.slice(0, 100);
|
|
984
|
-
if (existing.includes(fingerprint)) {
|
|
985
|
-
logger.info(`[engram] Duplicate detected, skipping write to short-term/${date}.md`);
|
|
986
|
-
}
|
|
987
|
-
else {
|
|
988
|
-
// ---- 保存中间文件(staging)供调试 ----
|
|
989
|
-
if (config.saveStagingFile !== false) {
|
|
990
|
-
const stagingDir = path.join(workspaceDir, "memory-engram", "staging");
|
|
991
|
-
await fs.mkdir(stagingDir, { recursive: true });
|
|
992
|
-
const stagingPath = path.join(stagingDir, `${date}-${timestamp.replace(/:/g, "")}.md`);
|
|
993
|
-
await fs.writeFile(stagingPath, sessionContent, "utf-8");
|
|
994
|
-
logger.info(`[engram] Saved staging file: ${stagingPath} (${sessionContent.length} chars)`);
|
|
995
|
-
}
|
|
996
|
-
// ---- 异步执行 LLM 精简 + 保存 + BM25 重建 ----
|
|
997
|
-
// 使用 fire-and-forget 模式,让钩子快速返回,避免阻塞 openclaw 事件循环
|
|
998
|
-
const asyncSessionContent = sessionContent; // 捕获变量
|
|
999
|
-
const asyncFilePath = filePath;
|
|
1000
|
-
const asyncTimestamp = timestamp;
|
|
1001
|
-
const asyncFingerprint = fingerprint;
|
|
1002
|
-
// ★ 异步任务启动前先检查指纹锁,防止并发任务
|
|
1003
|
-
if (processingFingerprints.has(asyncFingerprint)) {
|
|
1004
|
-
logger.info(`[engram] Content already being processed (fingerprint lock), skipping`);
|
|
1005
|
-
}
|
|
1006
|
-
else {
|
|
1007
|
-
processingFingerprints.add(asyncFingerprint);
|
|
1008
|
-
// 不 await,让它在后台执行
|
|
1009
|
-
(async () => {
|
|
1010
|
-
try {
|
|
1011
|
-
let contentToSave = asyncSessionContent;
|
|
1012
|
-
const condensed = await condenseSessionContent(asyncSessionContent, config);
|
|
1013
|
-
if (condensed) {
|
|
1014
|
-
contentToSave = condensed;
|
|
1015
|
-
logger.info(`[engram] [async] Using condensed content for short-term memory`);
|
|
1016
|
-
}
|
|
1017
|
-
else {
|
|
1018
|
-
logger.info(`[engram] [async] Condense failed or disabled, saving raw content`);
|
|
1019
|
-
}
|
|
1020
|
-
// ★ 写入前再次检查文件,防止并发写入
|
|
1021
|
-
let existingNow = "";
|
|
1022
|
-
try {
|
|
1023
|
-
existingNow = await fs.readFile(asyncFilePath, "utf-8");
|
|
1024
|
-
}
|
|
1025
|
-
catch { }
|
|
1026
|
-
if (existingNow.includes(asyncFingerprint)) {
|
|
1027
|
-
logger.info(`[engram] [async] Duplicate detected before write, skipping`);
|
|
1028
|
-
return;
|
|
1029
|
-
}
|
|
1030
|
-
const entry = `\n### ${asyncTimestamp} [对话记录]\n${contentToSave}\n`;
|
|
1031
|
-
await fs.appendFile(asyncFilePath, entry, "utf-8");
|
|
1032
|
-
logger.info(`[engram] [async] Saved ${contentToSave.length} chars of session content to short-term memory`);
|
|
1033
|
-
// Rebuild BM25 index after adding new entry
|
|
1034
|
-
await recallEngine.rebuildBM25();
|
|
1035
|
-
logger.info(`[engram] [async] BM25 index rebuilt after new entry added`);
|
|
1036
|
-
}
|
|
1037
|
-
catch (asyncErr) {
|
|
1038
|
-
logger.error(`[engram] [async] Background processing failed: ${asyncErr}`);
|
|
1039
|
-
}
|
|
1040
|
-
finally {
|
|
1041
|
-
// 无论成功失败,都释放指纹锁
|
|
1042
|
-
processingFingerprints.delete(asyncFingerprint);
|
|
1043
|
-
}
|
|
1044
|
-
})();
|
|
1045
|
-
logger.info(`[engram] Hook returning immediately, LLM condense + save running in background`);
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
else {
|
|
1050
|
-
// 没有对话内容,跳过写入(不再写无意义的 "Session reset" 垃圾)
|
|
1051
|
-
// session 已在前面标记,无需再处理
|
|
1052
|
-
logger.info(`[engram] No session content to save, skipping write`);
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
catch (err) {
|
|
1056
|
-
logger.error(`[engram] Hook command:${event?.action ?? "new/reset"} failed: ${err}`);
|
|
1057
|
-
}
|
|
1058
|
-
}, {
|
|
1059
|
-
name: "engram.command-session-reset",
|
|
1060
|
-
description: "会话重置(/new 或 /reset)时提取上一轮对话写入短期记忆",
|
|
1061
|
-
});
|
|
1062
|
-
// ---- Hooks: session:compact:before 保底沉淀 ----
|
|
1063
|
-
api.registerHook("session:compact:before", async (event) => {
|
|
1064
|
-
try {
|
|
1065
|
-
const date = new Date().toISOString().split("T")[0];
|
|
1066
|
-
const shortTermDir = path.join(workspaceDir, "memory-engram", "short-term");
|
|
1067
|
-
await fs.mkdir(shortTermDir, { recursive: true });
|
|
1068
|
-
const filePath = path.join(shortTermDir, `${date}.md`);
|
|
1069
|
-
const timestamp = new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
|
1070
|
-
const entry = `\n### ${timestamp} [系统]\nCompaction triggered, saving context at ${new Date().toISOString()}\n`;
|
|
1071
|
-
await fs.appendFile(filePath, entry, "utf-8");
|
|
1072
|
-
logger.info(`[engram] Pre-compaction save: ${date}`);
|
|
1073
|
-
}
|
|
1074
|
-
catch (err) {
|
|
1075
|
-
logger.error(`[engram] Hook session:compact:before failed: ${err}`);
|
|
1076
|
-
}
|
|
1077
|
-
}, {
|
|
1078
|
-
name: "engram.compact-before",
|
|
1079
|
-
description: "Compaction 前保底保存重要信息",
|
|
1080
|
-
});
|
|
1081
|
-
// ---- Service: 启动初始化 ----
|
|
1082
|
-
api.registerService({
|
|
1083
|
-
id: "engram",
|
|
1084
|
-
async start() {
|
|
1085
|
-
if (vectorStore) {
|
|
1086
|
-
await vectorStore.startup();
|
|
1087
|
-
}
|
|
1088
|
-
await recallEngine.startup();
|
|
1089
|
-
logger.info(`[engram] Service started (workspace: ${workspaceDir})`);
|
|
1090
|
-
},
|
|
1091
|
-
async stop() {
|
|
1092
|
-
logger.info("[engram] Service stopped");
|
|
1093
|
-
},
|
|
1094
|
-
});
|
|
1095
|
-
// ---- CLI Commands ----
|
|
1096
|
-
api.registerCli(({ program }) => {
|
|
1097
|
-
const mem = program.command("memory-sys").description("Memory system commands");
|
|
1098
|
-
mem.command("settle").description("Run daily settlement manually").action(async () => {
|
|
1099
|
-
const results = await runSettlement({
|
|
1100
|
-
workspaceDir,
|
|
1101
|
-
config,
|
|
1102
|
-
llmCall: makeLlmCall(config),
|
|
1103
|
-
vectorStore: vectorStore ? (text, category, importance) => vectorStore.store(text, category, importance) : undefined,
|
|
1104
|
-
vectorPrune: vectorStore ? (threshold, maxPrune) => vectorStore.prune(threshold, maxPrune) : undefined,
|
|
1105
|
-
});
|
|
1106
|
-
results.forEach(r => console.log(` ${r}`));
|
|
1107
|
-
});
|
|
1108
|
-
mem.command("monthly-settle").description("Run monthly settle (selective vectorize + prune)").action(async () => {
|
|
1109
|
-
console.log("Running monthly settlement...");
|
|
1110
|
-
if (vectorStore) {
|
|
1111
|
-
await vectorStore.startup();
|
|
1112
|
-
}
|
|
1113
|
-
const results = await runMonthlySettle({
|
|
1114
|
-
workspaceDir,
|
|
1115
|
-
config,
|
|
1116
|
-
llmCall: makeLlmCall(config),
|
|
1117
|
-
vectorStore: vectorStore ? (text, category, importance) => vectorStore.store(text, category, importance) : undefined,
|
|
1118
|
-
vectorPrune: vectorStore ? (threshold, maxPrune) => vectorStore.prune(threshold, maxPrune) : undefined,
|
|
1119
|
-
});
|
|
1120
|
-
results.forEach(r => console.log(` ${r}`));
|
|
1121
|
-
});
|
|
1122
|
-
mem.command("profile").description("Show user profile").action(async () => {
|
|
1123
|
-
const profile = await profileManager.load();
|
|
1124
|
-
const context = profileManager.getRecallContext(profile);
|
|
1125
|
-
console.log(context || "(empty profile)");
|
|
1126
|
-
console.log("\nFull tags:");
|
|
1127
|
-
console.log(JSON.stringify(profile.tags, null, 2));
|
|
1128
|
-
});
|
|
1129
|
-
mem.command("stats").description("Show memory stats").action(async () => {
|
|
1130
|
-
console.log(`Workspace: ${workspaceDir}`);
|
|
1131
|
-
console.log(`Short-term days: ${config.shortTermDays}`);
|
|
1132
|
-
console.log(`Half-life: ${config.halfLifeDays} days`);
|
|
1133
|
-
// 可扩展更多统计
|
|
1134
|
-
});
|
|
1135
|
-
}, { commands: ["memory-sys"] });
|
|
1136
|
-
logger.info("[engram] Plugin registered");
|
|
1137
|
-
}
|
|
1138
|
-
//# sourceMappingURL=index.js.map
|