openclaw-mem 1.0.3 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOOK.md +125 -0
- package/LICENSE +1 -1
- package/MCP.json +11 -0
- package/README.md +140 -129
- package/context-builder.js +703 -0
- package/database.js +520 -0
- package/debug-logger.js +280 -0
- package/extractor.js +211 -0
- package/gateway-llm.js +155 -0
- package/handler.js +1122 -0
- package/mcp-http-api.js +356 -0
- package/mcp-server.js +525 -0
- package/mem-get.sh +24 -0
- package/mem-search.sh +17 -0
- package/monitor.js +112 -0
- package/package.json +53 -29
- package/realtime-monitor.js +371 -0
- package/session-watcher.js +192 -0
- package/setup.js +114 -0
- package/sync-recent.js +63 -0
- package/README_CN.md +0 -184
- package/bin/openclaw-mem.js +0 -117
- package/lib/context-builder.js +0 -415
- package/lib/database.js +0 -309
- package/lib/handler.js +0 -494
- package/scripts/commands.js +0 -141
- package/scripts/init.js +0 -248
package/debug-logger.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* OpenClaw-Mem 调试日志工具
|
|
4
|
+
*
|
|
5
|
+
* 显示完整的 API 调用流程:
|
|
6
|
+
* 1. 用户消息
|
|
7
|
+
* 2. AI 调用的 exec 命令
|
|
8
|
+
* 3. API 收到的请求
|
|
9
|
+
* 4. API 返回的响应
|
|
10
|
+
* 5. AI 最终回复
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import os from 'os';
|
|
16
|
+
import http from 'http';
|
|
17
|
+
|
|
18
|
+
const SESSIONS_DIR = path.join(os.homedir(), '.openclaw', 'agents', 'main', 'sessions');
|
|
19
|
+
const LOG_FILE = '/tmp/openclaw-debug.log';
|
|
20
|
+
|
|
21
|
+
// 颜色
|
|
22
|
+
const c = {
|
|
23
|
+
reset: '\x1b[0m',
|
|
24
|
+
bold: '\x1b[1m',
|
|
25
|
+
dim: '\x1b[2m',
|
|
26
|
+
red: '\x1b[31m',
|
|
27
|
+
green: '\x1b[32m',
|
|
28
|
+
yellow: '\x1b[33m',
|
|
29
|
+
blue: '\x1b[34m',
|
|
30
|
+
magenta: '\x1b[35m',
|
|
31
|
+
cyan: '\x1b[36m',
|
|
32
|
+
bgRed: '\x1b[41m',
|
|
33
|
+
bgGreen: '\x1b[42m',
|
|
34
|
+
bgYellow: '\x1b[43m',
|
|
35
|
+
bgBlue: '\x1b[44m',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function log(msg) {
|
|
39
|
+
const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
|
40
|
+
const line = `[${ts}] ${msg}`;
|
|
41
|
+
console.log(line);
|
|
42
|
+
fs.appendFileSync(LOG_FILE, line.replace(/\x1b\[[0-9;]*m/g, '') + '\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function logSection(title, color = 'cyan') {
|
|
46
|
+
console.log();
|
|
47
|
+
console.log(`${c[color]}${'═'.repeat(60)}${c.reset}`);
|
|
48
|
+
console.log(`${c[color]}${c.bold} ${title}${c.reset}`);
|
|
49
|
+
console.log(`${c[color]}${'═'.repeat(60)}${c.reset}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function logEvent(icon, label, content, color = 'white') {
|
|
53
|
+
const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
|
54
|
+
console.log();
|
|
55
|
+
console.log(`${c.dim}[${ts}]${c.reset} ${icon} ${c[color]}${c.bold}${label}${c.reset}`);
|
|
56
|
+
if (content) {
|
|
57
|
+
const lines = String(content).split('\n').slice(0, 15);
|
|
58
|
+
lines.forEach(line => {
|
|
59
|
+
console.log(` ${c.dim}│${c.reset} ${line.slice(0, 120)}`);
|
|
60
|
+
});
|
|
61
|
+
if (String(content).split('\n').length > 15) {
|
|
62
|
+
console.log(` ${c.dim}│ ... (更多内容省略)${c.reset}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
fs.appendFileSync(LOG_FILE, `[${ts}] ${icon} ${label}\n${content || ''}\n\n`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 监控 Session 文件
|
|
69
|
+
let lastSessionFile = null;
|
|
70
|
+
let processedLines = new Set();
|
|
71
|
+
|
|
72
|
+
function findLatestSession() {
|
|
73
|
+
try {
|
|
74
|
+
const files = fs.readdirSync(SESSIONS_DIR)
|
|
75
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
76
|
+
.map(f => ({
|
|
77
|
+
name: f,
|
|
78
|
+
path: path.join(SESSIONS_DIR, f),
|
|
79
|
+
mtime: fs.statSync(path.join(SESSIONS_DIR, f)).mtime
|
|
80
|
+
}))
|
|
81
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
82
|
+
return files[0]?.path || null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function watchSession() {
|
|
89
|
+
const latestSession = findLatestSession();
|
|
90
|
+
|
|
91
|
+
if (latestSession !== lastSessionFile) {
|
|
92
|
+
lastSessionFile = latestSession;
|
|
93
|
+
processedLines.clear();
|
|
94
|
+
if (latestSession) {
|
|
95
|
+
logEvent('📁', '监控会话文件', path.basename(latestSession), 'cyan');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!lastSessionFile) return;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const content = fs.readFileSync(lastSessionFile, 'utf-8');
|
|
103
|
+
const lines = content.trim().split('\n');
|
|
104
|
+
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
const lineKey = line.slice(0, 150);
|
|
107
|
+
if (processedLines.has(lineKey)) continue;
|
|
108
|
+
processedLines.add(lineKey);
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const entry = JSON.parse(line);
|
|
112
|
+
if (entry.type !== 'message') continue;
|
|
113
|
+
|
|
114
|
+
const msg = entry.message;
|
|
115
|
+
if (!msg) continue;
|
|
116
|
+
|
|
117
|
+
// 用户消息
|
|
118
|
+
if (msg.role === 'user') {
|
|
119
|
+
let text = '';
|
|
120
|
+
if (Array.isArray(msg.content)) {
|
|
121
|
+
const textPart = msg.content.find(c => c.type === 'text');
|
|
122
|
+
text = textPart?.text || '';
|
|
123
|
+
} else {
|
|
124
|
+
text = msg.content || '';
|
|
125
|
+
}
|
|
126
|
+
if (text && !text.startsWith('A new session')) {
|
|
127
|
+
logEvent('👤', '用户消息', text.slice(0, 300), 'green');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// AI 响应
|
|
132
|
+
if (msg.role === 'assistant' && msg.content) {
|
|
133
|
+
// 检查工具调用
|
|
134
|
+
if (Array.isArray(msg.content)) {
|
|
135
|
+
for (const block of msg.content) {
|
|
136
|
+
if (block.type === 'toolCall') {
|
|
137
|
+
const toolName = block.name;
|
|
138
|
+
const args = block.arguments;
|
|
139
|
+
|
|
140
|
+
if (toolName === 'exec') {
|
|
141
|
+
let argsObj;
|
|
142
|
+
try {
|
|
143
|
+
argsObj = typeof args === 'string' ? JSON.parse(args) : args;
|
|
144
|
+
} catch {
|
|
145
|
+
argsObj = { command: args };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
logEvent('🔧', `工具调用: ${toolName}`,
|
|
149
|
+
`命令: ${argsObj.command || JSON.stringify(argsObj)}`, 'yellow');
|
|
150
|
+
|
|
151
|
+
// 高亮显示 curl 命令
|
|
152
|
+
if (argsObj.command && argsObj.command.includes('curl')) {
|
|
153
|
+
console.log(` ${c.bgYellow}${c.bold} CURL 命令 ${c.reset}`);
|
|
154
|
+
console.log(` ${c.yellow}${argsObj.command}${c.reset}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (block.type === 'text' && block.text) {
|
|
160
|
+
logEvent('🤖', 'AI 响应', block.text.slice(0, 400), 'blue');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 工具结果
|
|
167
|
+
if (msg.role === 'toolResult') {
|
|
168
|
+
const toolName = msg.toolName || 'unknown';
|
|
169
|
+
let result = '';
|
|
170
|
+
if (Array.isArray(msg.content)) {
|
|
171
|
+
const textPart = msg.content.find(c => c.type === 'text');
|
|
172
|
+
result = textPart?.text || '';
|
|
173
|
+
} else {
|
|
174
|
+
result = msg.content || '';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (toolName === 'exec') {
|
|
178
|
+
const isEmpty = !result || result === '(no output)';
|
|
179
|
+
const color = isEmpty ? 'red' : 'green';
|
|
180
|
+
const icon = isEmpty ? '❌' : '✅';
|
|
181
|
+
logEvent(icon, `exec 返回结果`, result || '(空)', color);
|
|
182
|
+
|
|
183
|
+
if (isEmpty) {
|
|
184
|
+
console.log(` ${c.bgRed}${c.bold} 警告: exec 返回空!curl 可能失败了 ${c.reset}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
} catch (e) {
|
|
190
|
+
// 解析失败,忽略
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// 文件读取失败
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 创建代理 API 服务器来拦截请求
|
|
199
|
+
const PROXY_PORT = 18791;
|
|
200
|
+
const TARGET_PORT = 18790;
|
|
201
|
+
|
|
202
|
+
function startProxyServer() {
|
|
203
|
+
const proxy = http.createServer((req, res) => {
|
|
204
|
+
let body = '';
|
|
205
|
+
req.on('data', chunk => body += chunk);
|
|
206
|
+
req.on('end', () => {
|
|
207
|
+
// 记录请求
|
|
208
|
+
logEvent('📥', `API 请求: ${req.method} ${req.url}`,
|
|
209
|
+
body ? `Body: ${body}` : '(无 body)', 'magenta');
|
|
210
|
+
|
|
211
|
+
// 转发到真实 API
|
|
212
|
+
const options = {
|
|
213
|
+
hostname: '127.0.0.1',
|
|
214
|
+
port: TARGET_PORT,
|
|
215
|
+
path: req.url,
|
|
216
|
+
method: req.method,
|
|
217
|
+
headers: req.headers
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const proxyReq = http.request(options, (proxyRes) => {
|
|
221
|
+
let responseBody = '';
|
|
222
|
+
proxyRes.on('data', chunk => responseBody += chunk);
|
|
223
|
+
proxyRes.on('end', () => {
|
|
224
|
+
// 记录响应
|
|
225
|
+
const preview = responseBody.slice(0, 500);
|
|
226
|
+
logEvent('📤', `API 响应 (${proxyRes.statusCode})`, preview,
|
|
227
|
+
proxyRes.statusCode === 200 ? 'green' : 'red');
|
|
228
|
+
|
|
229
|
+
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
230
|
+
res.end(responseBody);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
proxyReq.on('error', (err) => {
|
|
235
|
+
logEvent('❌', 'API 请求失败', err.message, 'red');
|
|
236
|
+
res.writeHead(500);
|
|
237
|
+
res.end('Proxy error');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
if (body) proxyReq.write(body);
|
|
241
|
+
proxyReq.end();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
proxy.listen(PROXY_PORT, '127.0.0.1', () => {
|
|
246
|
+
log(`${c.green}代理服务器运行在 http://127.0.0.1:${PROXY_PORT}${c.reset}`);
|
|
247
|
+
log(`${c.dim}(转发到 http://127.0.0.1:${TARGET_PORT})${c.reset}`);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
return proxy;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 主函数
|
|
254
|
+
function main() {
|
|
255
|
+
// 清空日志文件
|
|
256
|
+
fs.writeFileSync(LOG_FILE, '');
|
|
257
|
+
|
|
258
|
+
logSection('OpenClaw-Mem 调试日志工具', 'cyan');
|
|
259
|
+
log('');
|
|
260
|
+
log(`${c.bold}监控内容:${c.reset}`);
|
|
261
|
+
log(` • Session 文件: ${SESSIONS_DIR}`);
|
|
262
|
+
log(` • 日志文件: ${LOG_FILE}`);
|
|
263
|
+
log('');
|
|
264
|
+
log(`${c.yellow}提示: 按 Ctrl+C 退出${c.reset}`);
|
|
265
|
+
log('');
|
|
266
|
+
|
|
267
|
+
// 开始监控
|
|
268
|
+
setInterval(watchSession, 300);
|
|
269
|
+
|
|
270
|
+
logSection('等待事件...', 'dim');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
main();
|
|
274
|
+
|
|
275
|
+
process.on('SIGINT', () => {
|
|
276
|
+
console.log();
|
|
277
|
+
log(`${c.yellow}调试日志已停止${c.reset}`);
|
|
278
|
+
log(`${c.dim}完整日志保存在: ${LOG_FILE}${c.reset}`);
|
|
279
|
+
process.exit(0);
|
|
280
|
+
});
|
package/extractor.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw-Mem LLM Extractor
|
|
3
|
+
* Uses the local OpenClaw Gateway model to extract concepts and metadata
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { callGatewayChat } from './gateway-llm.js';
|
|
7
|
+
|
|
8
|
+
// Cache for extracted concepts (to avoid repeated API calls)
|
|
9
|
+
const conceptCache = new Map();
|
|
10
|
+
const CACHE_MAX_SIZE = 1000;
|
|
11
|
+
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
|
|
12
|
+
|
|
13
|
+
function getCacheKey(text) {
|
|
14
|
+
// Simple hash for cache key
|
|
15
|
+
let hash = 0;
|
|
16
|
+
const str = text.slice(0, 500); // Only hash first 500 chars
|
|
17
|
+
for (let i = 0; i < str.length; i++) {
|
|
18
|
+
const char = str.charCodeAt(i);
|
|
19
|
+
hash = ((hash << 5) - hash) + char;
|
|
20
|
+
hash = hash & hash;
|
|
21
|
+
}
|
|
22
|
+
return hash.toString(16);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function cleanCache() {
|
|
26
|
+
if (conceptCache.size > CACHE_MAX_SIZE) {
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
for (const [key, value] of conceptCache.entries()) {
|
|
29
|
+
if (now - value.timestamp > CACHE_TTL) {
|
|
30
|
+
conceptCache.delete(key);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// If still too large, remove oldest entries
|
|
34
|
+
if (conceptCache.size > CACHE_MAX_SIZE) {
|
|
35
|
+
const entries = [...conceptCache.entries()];
|
|
36
|
+
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
|
|
37
|
+
const toRemove = entries.slice(0, entries.length - CACHE_MAX_SIZE / 2);
|
|
38
|
+
for (const [key] of toRemove) {
|
|
39
|
+
conceptCache.delete(key);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extract concepts/keywords from text using LLM
|
|
47
|
+
* @param {string} text - The text to extract concepts from
|
|
48
|
+
* @param {object} options - Options
|
|
49
|
+
* @returns {Promise<string[]>} - Array of extracted concepts
|
|
50
|
+
*/
|
|
51
|
+
export async function extractConcepts(text, options = {}) {
|
|
52
|
+
if (!text || typeof text !== 'string' || text.trim().length < 10) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check cache first
|
|
57
|
+
const cacheKey = getCacheKey(text);
|
|
58
|
+
const cached = conceptCache.get(cacheKey);
|
|
59
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
60
|
+
return cached.concepts;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const content = await callGatewayChat([{
|
|
65
|
+
role: 'user',
|
|
66
|
+
content: `Extract 3-7 key concepts/topics from this text. Return ONLY a JSON array of strings, no explanation.
|
|
67
|
+
|
|
68
|
+
Text: "${text.slice(0, 800)}"
|
|
69
|
+
|
|
70
|
+
JSON array:`
|
|
71
|
+
}], { sessionKey: 'extract-concepts', temperature: 0.2, max_tokens: 200 });
|
|
72
|
+
|
|
73
|
+
if (!content) return [];
|
|
74
|
+
// Parse JSON array from response
|
|
75
|
+
let concepts = [];
|
|
76
|
+
try {
|
|
77
|
+
// Try to extract JSON array from response
|
|
78
|
+
const match = content.match(/\[[\s\S]*?\]/);
|
|
79
|
+
if (match) {
|
|
80
|
+
concepts = JSON.parse(match[0]);
|
|
81
|
+
}
|
|
82
|
+
} catch (parseErr) {
|
|
83
|
+
console.error('[openclaw-mem] Failed to parse LLM response:', parseErr.message);
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Validate and clean concepts
|
|
88
|
+
concepts = concepts
|
|
89
|
+
.filter(c => typeof c === 'string' && c.length > 1 && c.length < 50)
|
|
90
|
+
.map(c => c.trim().toLowerCase())
|
|
91
|
+
.slice(0, 7);
|
|
92
|
+
|
|
93
|
+
// Cache the result
|
|
94
|
+
cleanCache();
|
|
95
|
+
conceptCache.set(cacheKey, {
|
|
96
|
+
concepts,
|
|
97
|
+
timestamp: Date.now()
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return concepts;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error('[openclaw-mem] LLM extraction error:', err.message);
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Extract structured information from a tool call
|
|
109
|
+
* @param {object} data - Tool call data
|
|
110
|
+
* @returns {Promise<object>} - Extracted information
|
|
111
|
+
*/
|
|
112
|
+
export async function extractFromToolCall(data) {
|
|
113
|
+
const { tool_name, tool_input, tool_response, filesRead, filesModified } = data;
|
|
114
|
+
|
|
115
|
+
// Build context for extraction
|
|
116
|
+
const inputStr = typeof tool_input === 'string'
|
|
117
|
+
? tool_input.slice(0, 300)
|
|
118
|
+
: JSON.stringify(tool_input).slice(0, 300);
|
|
119
|
+
|
|
120
|
+
const responseStr = typeof tool_response === 'string'
|
|
121
|
+
? tool_response.slice(0, 300)
|
|
122
|
+
: JSON.stringify(tool_response).slice(0, 300);
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const content = await callGatewayChat([{
|
|
126
|
+
role: 'user',
|
|
127
|
+
content: `Analyze this tool call and extract structured information. Return ONLY valid JSON.
|
|
128
|
+
|
|
129
|
+
Tool: ${tool_name}
|
|
130
|
+
Input: ${inputStr}
|
|
131
|
+
Output: ${responseStr}
|
|
132
|
+
Files read: ${filesRead?.join(', ') || 'none'}
|
|
133
|
+
Files modified: ${filesModified?.join(', ') || 'none'}
|
|
134
|
+
|
|
135
|
+
Return JSON with these fields:
|
|
136
|
+
{
|
|
137
|
+
"type": "decision|bugfix|feature|refactor|discovery|testing|setup|other",
|
|
138
|
+
"narrative": "One sentence describing what happened",
|
|
139
|
+
"facts": ["fact1", "fact2"],
|
|
140
|
+
"concepts": ["keyword1", "keyword2", "keyword3"]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
JSON:`
|
|
144
|
+
}], { sessionKey: 'extract-toolcall', temperature: 0.2, max_tokens: 300 });
|
|
145
|
+
|
|
146
|
+
if (!content) throw new Error('empty response');
|
|
147
|
+
|
|
148
|
+
// Parse JSON from response
|
|
149
|
+
const match = content.match(/\{[\s\S]*\}/);
|
|
150
|
+
if (match) {
|
|
151
|
+
const result = JSON.parse(match[0]);
|
|
152
|
+
return {
|
|
153
|
+
type: result.type || 'other',
|
|
154
|
+
narrative: result.narrative || '',
|
|
155
|
+
facts: Array.isArray(result.facts) ? result.facts.slice(0, 5) : [],
|
|
156
|
+
concepts: Array.isArray(result.concepts) ? result.concepts.slice(0, 7) : []
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.error('[openclaw-mem] Tool extraction error:', err.message);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Return empty result on error
|
|
164
|
+
return {
|
|
165
|
+
type: 'other',
|
|
166
|
+
narrative: '',
|
|
167
|
+
facts: [],
|
|
168
|
+
concepts: []
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Batch extract concepts from multiple texts
|
|
174
|
+
* @param {string[]} texts - Array of texts to extract from
|
|
175
|
+
* @returns {Promise<Map<string, string[]>>} - Map of text to concepts
|
|
176
|
+
*/
|
|
177
|
+
export async function batchExtractConcepts(texts) {
|
|
178
|
+
const results = new Map();
|
|
179
|
+
|
|
180
|
+
// Filter out cached results first
|
|
181
|
+
const uncached = [];
|
|
182
|
+
for (const text of texts) {
|
|
183
|
+
const cacheKey = getCacheKey(text);
|
|
184
|
+
const cached = conceptCache.get(cacheKey);
|
|
185
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
186
|
+
results.set(text, cached.concepts);
|
|
187
|
+
} else {
|
|
188
|
+
uncached.push(text);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Process uncached in batches
|
|
193
|
+
const BATCH_SIZE = 5;
|
|
194
|
+
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
|
|
195
|
+
const batch = uncached.slice(i, i + BATCH_SIZE);
|
|
196
|
+
const promises = batch.map(text => extractConcepts(text));
|
|
197
|
+
const batchResults = await Promise.all(promises);
|
|
198
|
+
|
|
199
|
+
for (let j = 0; j < batch.length; j++) {
|
|
200
|
+
results.set(batch[j], batchResults[j]);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return results;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export default {
|
|
208
|
+
extractConcepts,
|
|
209
|
+
extractFromToolCall,
|
|
210
|
+
batchExtractConcepts
|
|
211
|
+
};
|
package/gateway-llm.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek LLM helper
|
|
3
|
+
* Calls the DeepSeek OpenAI-compatible endpoint to summarize sessions.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const SUMMARY_SESSION_PREFIX = 'mem-summary:';
|
|
7
|
+
const DEFAULT_DEEPSEEK_BASE_URL = 'https://api.deepseek.com/v1';
|
|
8
|
+
const DEFAULT_DEEPSEEK_MODEL = 'deepseek-chat';
|
|
9
|
+
|
|
10
|
+
function getDeepSeekBaseUrl() {
|
|
11
|
+
return process.env.DEEPSEEK_BASE_URL
|
|
12
|
+
|| DEFAULT_DEEPSEEK_BASE_URL;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getDeepSeekApiKey() {
|
|
16
|
+
return process.env.DEEPSEEK_API_KEY || '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getDeepSeekModel() {
|
|
20
|
+
return process.env.DEEPSEEK_MODEL
|
|
21
|
+
|| DEFAULT_DEEPSEEK_MODEL;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function truncateText(text, maxChars) {
|
|
25
|
+
if (!text) return '';
|
|
26
|
+
if (text.length <= maxChars) return text;
|
|
27
|
+
return text.slice(0, maxChars) + '…';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatTranscript(messages, maxChars = 8000) {
|
|
31
|
+
const lines = [];
|
|
32
|
+
for (const m of messages) {
|
|
33
|
+
const role = (m.role || 'unknown').toUpperCase();
|
|
34
|
+
const content = String(m.content || '').replace(/\s+/g, ' ').trim();
|
|
35
|
+
if (!content) continue;
|
|
36
|
+
lines.push(`${role}: ${content}`);
|
|
37
|
+
}
|
|
38
|
+
return truncateText(lines.join('\n'), maxChars);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseSummaryJson(text) {
|
|
42
|
+
if (!text) return null;
|
|
43
|
+
const match = text.match(/\{[\s\S]*\}/);
|
|
44
|
+
if (!match) return null;
|
|
45
|
+
try {
|
|
46
|
+
const obj = JSON.parse(match[0]);
|
|
47
|
+
return obj && typeof obj === 'object' ? obj : null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeSummaryFields(obj) {
|
|
54
|
+
if (!obj) return null;
|
|
55
|
+
const pick = (key) => {
|
|
56
|
+
const val = obj[key];
|
|
57
|
+
if (typeof val === 'string') return val.trim();
|
|
58
|
+
if (val == null) return '';
|
|
59
|
+
return String(val).trim();
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
request: pick('request'),
|
|
63
|
+
learned: pick('learned'),
|
|
64
|
+
completed: pick('completed'),
|
|
65
|
+
next_steps: pick('next_steps')
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function callGatewayChat(messages, options = {}) {
|
|
70
|
+
const {
|
|
71
|
+
sessionKey = 'unknown',
|
|
72
|
+
temperature = 0.2,
|
|
73
|
+
max_tokens = 300,
|
|
74
|
+
model
|
|
75
|
+
} = options;
|
|
76
|
+
const apiKey = getDeepSeekApiKey();
|
|
77
|
+
if (!apiKey) {
|
|
78
|
+
console.log('[openclaw-mem] No DEEPSEEK_API_KEY found');
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const baseUrl = getDeepSeekBaseUrl();
|
|
82
|
+
const resolvedModel = model || getDeepSeekModel();
|
|
83
|
+
const url = `${baseUrl}/chat/completions`;
|
|
84
|
+
const payload = {
|
|
85
|
+
model: resolvedModel,
|
|
86
|
+
stream: false,
|
|
87
|
+
temperature,
|
|
88
|
+
max_tokens,
|
|
89
|
+
messages
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const headers = {
|
|
93
|
+
'Content-Type': 'application/json',
|
|
94
|
+
'Authorization': `Bearer ${apiKey}`
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
console.log('[openclaw-mem] Calling DeepSeek API...');
|
|
99
|
+
const res = await fetch(url, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers,
|
|
102
|
+
body: JSON.stringify(payload)
|
|
103
|
+
});
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
const errText = await res.text();
|
|
106
|
+
console.error('[openclaw-mem] DeepSeek API error:', res.status, errText);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const json = await res.json();
|
|
110
|
+
const content = json?.choices?.[0]?.message?.content || '';
|
|
111
|
+
console.log('[openclaw-mem] DeepSeek response received');
|
|
112
|
+
return content;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error('[openclaw-mem] DeepSeek fetch error:', err.message);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function summarizeSession(messages, options = {}) {
|
|
120
|
+
const { sessionKey = 'unknown' } = options;
|
|
121
|
+
const transcript = formatTranscript(messages);
|
|
122
|
+
if (!transcript) return null;
|
|
123
|
+
|
|
124
|
+
const buildPrompts = (strict = false) => {
|
|
125
|
+
const systemPrompt = [
|
|
126
|
+
'你是一个对话总结助手。请用中文总结这段对话,返回一个 JSON 对象,包含以下字段:',
|
|
127
|
+
'- request: 用户的主要问题或需求(一句话)',
|
|
128
|
+
'- learned: 用户从对话中学到了什么',
|
|
129
|
+
'- completed: 完成了什么任务或解答',
|
|
130
|
+
'- next_steps: 建议的下一步行动',
|
|
131
|
+
'只返回 JSON 对象,不要 markdown 代码块,不要其他内容。',
|
|
132
|
+
strict ? '重要:只输出纯 JSON,不要任何额外文字。' : ''
|
|
133
|
+
].filter(Boolean).join('\n');
|
|
134
|
+
const userPrompt = '对话记录:\n' + transcript + '\n\nJSON:';
|
|
135
|
+
return [
|
|
136
|
+
{ role: 'system', content: systemPrompt },
|
|
137
|
+
{ role: 'user', content: userPrompt }
|
|
138
|
+
];
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// First attempt
|
|
142
|
+
let content = await callGatewayChat(buildPrompts(false), { sessionKey, temperature: 0.2, max_tokens: 300 });
|
|
143
|
+
let parsed = parseSummaryJson(content || '');
|
|
144
|
+
if (parsed) return normalizeSummaryFields(parsed);
|
|
145
|
+
|
|
146
|
+
// Retry once with stricter instruction
|
|
147
|
+
content = await callGatewayChat(buildPrompts(true), { sessionKey, temperature: 0.2, max_tokens: 300 });
|
|
148
|
+
parsed = parseSummaryJson(content || '');
|
|
149
|
+
if (parsed) return normalizeSummaryFields(parsed);
|
|
150
|
+
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const INTERNAL_SUMMARY_PREFIX = SUMMARY_SESSION_PREFIX;
|
|
155
|
+
export { callGatewayChat };
|