@humanlikememory/human-like-mem 0.3.11 → 0.3.13
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 +70 -70
- package/README_ZH.md +70 -70
- package/index.js +762 -63
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1,70 +1,70 @@
|
|
|
1
|
-
# Human-Like Memory Plugin for OpenClaw
|
|
2
|
-
|
|
3
|
-
[](https://www.npmjs.com/package/@humanlikememory/human-like-mem)
|
|
4
|
-
|
|
5
|
-
Long-term memory plugin for OpenClaw with automatic memory recall and memory storage.
|
|
6
|
-
|
|
7
|
-
## Install
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
npm install @humanlikememory/human-like-mem
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
## Enable In OpenClaw
|
|
14
|
-
|
|
15
|
-
Edit `~/.openclaw/openclaw.json`:
|
|
16
|
-
|
|
17
|
-
```json
|
|
18
|
-
{
|
|
19
|
-
"plugins": {
|
|
20
|
-
"entries": {
|
|
21
|
-
"human-like-mem": {
|
|
22
|
-
"enabled": true
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
## Required Environment Variables
|
|
30
|
-
|
|
31
|
-
```bash
|
|
32
|
-
export HUMAN_LIKE_MEM_API_KEY="mp_xxxxxx"
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
## Optional Environment Variables
|
|
36
|
-
|
|
37
|
-
```bash
|
|
38
|
-
export HUMAN_LIKE_MEM_BASE_URL="https://human-like.me"
|
|
39
|
-
export HUMAN_LIKE_MEM_USER_ID="your-user-id"
|
|
40
|
-
export HUMAN_LIKE_MEM_AGENT_ID="main"
|
|
41
|
-
export HUMAN_LIKE_MEM_LIMIT_NUMBER="6"
|
|
42
|
-
export HUMAN_LIKE_MEM_MIN_SCORE="0.1"
|
|
43
|
-
export HUMAN_LIKE_MEM_MIN_TURNS="5"
|
|
44
|
-
export HUMAN_LIKE_MEM_SESSION_TIMEOUT="300000"
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
## How It Works
|
|
48
|
-
|
|
49
|
-
- Before response: the plugin retrieves relevant memories and injects them into context.
|
|
50
|
-
- After response: the plugin caches the conversation and flushes memory by turn threshold or timeout.
|
|
51
|
-
|
|
52
|
-
Default storage behavior:
|
|
53
|
-
|
|
54
|
-
- `minTurnsToStore = 5`
|
|
55
|
-
- `maxTurnsToStore = minTurnsToStore * 2`
|
|
56
|
-
- `sessionTimeoutMs = 300000` (5 minutes)
|
|
57
|
-
|
|
58
|
-
## Troubleshooting
|
|
59
|
-
|
|
60
|
-
- If you see `HUMAN_LIKE_MEM_API_KEY not configured`, make sure the key is set in your runtime environment.
|
|
61
|
-
- If you see request timeout logs, increase plugin `timeoutMs` (for example `30000`).
|
|
62
|
-
- Check logs with:
|
|
63
|
-
|
|
64
|
-
```bash
|
|
65
|
-
openclaw logs --plain --limit 200
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
## License
|
|
69
|
-
|
|
70
|
-
Apache-2.0
|
|
1
|
+
# Human-Like Memory Plugin for OpenClaw
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@humanlikememory/human-like-mem)
|
|
4
|
+
|
|
5
|
+
Long-term memory plugin for OpenClaw with automatic memory recall and memory storage.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @humanlikememory/human-like-mem
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Enable In OpenClaw
|
|
14
|
+
|
|
15
|
+
Edit `~/.openclaw/openclaw.json`:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"plugins": {
|
|
20
|
+
"entries": {
|
|
21
|
+
"human-like-mem": {
|
|
22
|
+
"enabled": true
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Required Environment Variables
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
export HUMAN_LIKE_MEM_API_KEY="mp_xxxxxx"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Optional Environment Variables
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
export HUMAN_LIKE_MEM_BASE_URL="https://plugin.human-like.me"
|
|
39
|
+
export HUMAN_LIKE_MEM_USER_ID="your-user-id"
|
|
40
|
+
export HUMAN_LIKE_MEM_AGENT_ID="main"
|
|
41
|
+
export HUMAN_LIKE_MEM_LIMIT_NUMBER="6"
|
|
42
|
+
export HUMAN_LIKE_MEM_MIN_SCORE="0.1"
|
|
43
|
+
export HUMAN_LIKE_MEM_MIN_TURNS="5"
|
|
44
|
+
export HUMAN_LIKE_MEM_SESSION_TIMEOUT="300000"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## How It Works
|
|
48
|
+
|
|
49
|
+
- Before response: the plugin retrieves relevant memories and injects them into context.
|
|
50
|
+
- After response: the plugin caches the conversation and flushes memory by turn threshold or timeout.
|
|
51
|
+
|
|
52
|
+
Default storage behavior:
|
|
53
|
+
|
|
54
|
+
- `minTurnsToStore = 5`
|
|
55
|
+
- `maxTurnsToStore = minTurnsToStore * 2`
|
|
56
|
+
- `sessionTimeoutMs = 300000` (5 minutes)
|
|
57
|
+
|
|
58
|
+
## Troubleshooting
|
|
59
|
+
|
|
60
|
+
- If you see `HUMAN_LIKE_MEM_API_KEY not configured`, make sure the key is set in your runtime environment.
|
|
61
|
+
- If you see request timeout logs, increase plugin `timeoutMs` (for example `30000`).
|
|
62
|
+
- Check logs with:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
openclaw logs --plain --limit 200
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
Apache-2.0
|
package/README_ZH.md
CHANGED
|
@@ -1,70 +1,70 @@
|
|
|
1
|
-
# Human-Like Memory Plugin for OpenClaw
|
|
2
|
-
|
|
3
|
-
[](https://www.npmjs.com/package/@humanlikememory/human-like-mem)
|
|
4
|
-
|
|
5
|
-
OpenClaw 长期记忆插件,支持自动召回和自动存储对话记忆。
|
|
6
|
-
|
|
7
|
-
## 安装
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
npm install @humanlikememory/human-like-mem
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
## 在 OpenClaw 中启用
|
|
14
|
-
|
|
15
|
-
编辑 `~/.openclaw/openclaw.json`:
|
|
16
|
-
|
|
17
|
-
```json
|
|
18
|
-
{
|
|
19
|
-
"plugins": {
|
|
20
|
-
"entries": {
|
|
21
|
-
"human-like-mem": {
|
|
22
|
-
"enabled": true
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
## 必填环境变量
|
|
30
|
-
|
|
31
|
-
```bash
|
|
32
|
-
export HUMAN_LIKE_MEM_API_KEY="mp_xxxxxx"
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
## 可选环境变量
|
|
36
|
-
|
|
37
|
-
```bash
|
|
38
|
-
export HUMAN_LIKE_MEM_BASE_URL="https://human-like.me"
|
|
39
|
-
export HUMAN_LIKE_MEM_USER_ID="your-user-id"
|
|
40
|
-
export HUMAN_LIKE_MEM_AGENT_ID="main"
|
|
41
|
-
export HUMAN_LIKE_MEM_LIMIT_NUMBER="6"
|
|
42
|
-
export HUMAN_LIKE_MEM_MIN_SCORE="0.1"
|
|
43
|
-
export HUMAN_LIKE_MEM_MIN_TURNS="5"
|
|
44
|
-
export HUMAN_LIKE_MEM_SESSION_TIMEOUT="300000"
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
## 工作机制
|
|
48
|
-
|
|
49
|
-
- 回答前:插件检索相关记忆并注入上下文。
|
|
50
|
-
- 回答后:插件缓存会话,在满足轮次阈值或超时后写入记忆。
|
|
51
|
-
|
|
52
|
-
默认存储策略:
|
|
53
|
-
|
|
54
|
-
- `minTurnsToStore = 5`
|
|
55
|
-
- `maxTurnsToStore = minTurnsToStore * 2`
|
|
56
|
-
- `sessionTimeoutMs = 300000`(5 分钟)
|
|
57
|
-
|
|
58
|
-
## 常见问题
|
|
59
|
-
|
|
60
|
-
- 如果日志出现 `HUMAN_LIKE_MEM_API_KEY not configured`,请确认运行时环境变量已生效。
|
|
61
|
-
- 如果日志出现请求超时,请把插件 `timeoutMs` 调大(例如 `30000`)。
|
|
62
|
-
- 查看日志:
|
|
63
|
-
|
|
64
|
-
```bash
|
|
65
|
-
openclaw logs --plain --limit 200
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
## 许可证
|
|
69
|
-
|
|
70
|
-
Apache-2.0
|
|
1
|
+
# Human-Like Memory Plugin for OpenClaw
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@humanlikememory/human-like-mem)
|
|
4
|
+
|
|
5
|
+
OpenClaw 长期记忆插件,支持自动召回和自动存储对话记忆。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @humanlikememory/human-like-mem
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 在 OpenClaw 中启用
|
|
14
|
+
|
|
15
|
+
编辑 `~/.openclaw/openclaw.json`:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"plugins": {
|
|
20
|
+
"entries": {
|
|
21
|
+
"human-like-mem": {
|
|
22
|
+
"enabled": true
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 必填环境变量
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
export HUMAN_LIKE_MEM_API_KEY="mp_xxxxxx"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 可选环境变量
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
export HUMAN_LIKE_MEM_BASE_URL="https://plugin.human-like.me"
|
|
39
|
+
export HUMAN_LIKE_MEM_USER_ID="your-user-id"
|
|
40
|
+
export HUMAN_LIKE_MEM_AGENT_ID="main"
|
|
41
|
+
export HUMAN_LIKE_MEM_LIMIT_NUMBER="6"
|
|
42
|
+
export HUMAN_LIKE_MEM_MIN_SCORE="0.1"
|
|
43
|
+
export HUMAN_LIKE_MEM_MIN_TURNS="5"
|
|
44
|
+
export HUMAN_LIKE_MEM_SESSION_TIMEOUT="300000"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 工作机制
|
|
48
|
+
|
|
49
|
+
- 回答前:插件检索相关记忆并注入上下文。
|
|
50
|
+
- 回答后:插件缓存会话,在满足轮次阈值或超时后写入记忆。
|
|
51
|
+
|
|
52
|
+
默认存储策略:
|
|
53
|
+
|
|
54
|
+
- `minTurnsToStore = 5`
|
|
55
|
+
- `maxTurnsToStore = minTurnsToStore * 2`
|
|
56
|
+
- `sessionTimeoutMs = 300000`(5 分钟)
|
|
57
|
+
|
|
58
|
+
## 常见问题
|
|
59
|
+
|
|
60
|
+
- 如果日志出现 `HUMAN_LIKE_MEM_API_KEY not configured`,请确认运行时环境变量已生效。
|
|
61
|
+
- 如果日志出现请求超时,请把插件 `timeoutMs` 调大(例如 `30000`)。
|
|
62
|
+
- 查看日志:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
openclaw logs --plain --limit 200
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## 许可证
|
|
69
|
+
|
|
70
|
+
Apache-2.0
|
package/index.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const PLUGIN_VERSION = "0.3.4";
|
|
10
10
|
const USER_QUERY_MARKER = "--- User Query ---";
|
|
11
|
+
const CACHE_DEDUP_WINDOW_MS = 4000;
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Session cache for tracking conversation history
|
|
@@ -59,6 +60,127 @@ function extractText(content) {
|
|
|
59
60
|
return "";
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
function isToolCallBlock(block) {
|
|
64
|
+
if (!block || typeof block !== "object") return false;
|
|
65
|
+
const type = String(block.type || "").trim().toLowerCase();
|
|
66
|
+
return type === "toolcall" || type === "tool_call";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeToolCallBlock(block) {
|
|
70
|
+
if (!isToolCallBlock(block)) return null;
|
|
71
|
+
|
|
72
|
+
const name =
|
|
73
|
+
block.function?.name ||
|
|
74
|
+
block.toolName ||
|
|
75
|
+
block.name ||
|
|
76
|
+
"unknown";
|
|
77
|
+
const args =
|
|
78
|
+
block.function?.arguments ??
|
|
79
|
+
block.arguments ??
|
|
80
|
+
block.args ??
|
|
81
|
+
block.input ??
|
|
82
|
+
{};
|
|
83
|
+
const callId =
|
|
84
|
+
block.id ||
|
|
85
|
+
block.callId ||
|
|
86
|
+
block.toolCallId ||
|
|
87
|
+
block.tool_call_id ||
|
|
88
|
+
null;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
id: callId,
|
|
92
|
+
name,
|
|
93
|
+
arguments: args,
|
|
94
|
+
function: {
|
|
95
|
+
name,
|
|
96
|
+
arguments: args,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function extractToolCallsFromContent(content) {
|
|
102
|
+
if (!Array.isArray(content)) return [];
|
|
103
|
+
|
|
104
|
+
return content
|
|
105
|
+
.map((block) => normalizeToolCallBlock(block))
|
|
106
|
+
.filter(Boolean);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getMessageToolCalls(msg) {
|
|
110
|
+
if (!msg || typeof msg !== "object") return [];
|
|
111
|
+
if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
|
|
112
|
+
return msg.tool_calls;
|
|
113
|
+
}
|
|
114
|
+
return extractToolCallsFromContent(msg.content);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getToolResultCallId(msg) {
|
|
118
|
+
if (!msg || typeof msg !== "object") return null;
|
|
119
|
+
return (
|
|
120
|
+
msg.tool_call_id ||
|
|
121
|
+
msg.toolCallId ||
|
|
122
|
+
msg.call_id ||
|
|
123
|
+
msg.callId ||
|
|
124
|
+
null
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getToolResultName(msg) {
|
|
129
|
+
if (!msg || typeof msg !== "object") return undefined;
|
|
130
|
+
return msg.name || msg.toolName || msg.tool_name || undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeCacheRole(role) {
|
|
134
|
+
if (role === "toolResult") return "tool";
|
|
135
|
+
return role;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isAssistantLikeRole(role) {
|
|
139
|
+
return role === "assistant" || role === "tool" || role === "toolResult";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildCacheMessageFromTranscript(msg) {
|
|
143
|
+
if (!msg || msg.role === "system") return null;
|
|
144
|
+
|
|
145
|
+
const normalizedRole = normalizeCacheRole(msg.role);
|
|
146
|
+
let content = "";
|
|
147
|
+
let rawSource = msg.content;
|
|
148
|
+
let messageId = "";
|
|
149
|
+
|
|
150
|
+
if (msg.role === "user") {
|
|
151
|
+
content = normalizeUserMessageContent(msg.content);
|
|
152
|
+
rawSource = stripPrependedPrompt(msg.content);
|
|
153
|
+
messageId = extractRelayMessageId(rawSource || "");
|
|
154
|
+
} else if (msg.role === "assistant") {
|
|
155
|
+
content = normalizeAssistantMessageContent(msg.content);
|
|
156
|
+
rawSource = extractText(msg.content);
|
|
157
|
+
} else if (normalizedRole === "tool") {
|
|
158
|
+
content = extractText(msg.content);
|
|
159
|
+
rawSource = content;
|
|
160
|
+
} else {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const toolCalls = getMessageToolCalls(msg);
|
|
165
|
+
const toolCallId = getToolResultCallId(msg);
|
|
166
|
+
const toolName = getToolResultName(msg);
|
|
167
|
+
const hasToolContext =
|
|
168
|
+
(normalizedRole === "assistant" && toolCalls.length > 0) ||
|
|
169
|
+
(normalizedRole === "tool" && (!!toolCallId || !!toolName));
|
|
170
|
+
|
|
171
|
+
if (!content && !hasToolContext) return null;
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
role: normalizedRole,
|
|
175
|
+
content: content || "",
|
|
176
|
+
rawContent: rawSource || undefined,
|
|
177
|
+
messageId: messageId || undefined,
|
|
178
|
+
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
179
|
+
tool_call_id: toolCallId || undefined,
|
|
180
|
+
name: toolName,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
62
184
|
/**
|
|
63
185
|
* Strip prepended prompt markers from message content
|
|
64
186
|
*/
|
|
@@ -123,6 +245,65 @@ function isMetadataOnlyText(text) {
|
|
|
123
245
|
return false;
|
|
124
246
|
}
|
|
125
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Remove injected relay metadata blocks/lines from user content.
|
|
250
|
+
*/
|
|
251
|
+
function stripInjectedMetadata(text) {
|
|
252
|
+
if (!text || typeof text !== "string") return "";
|
|
253
|
+
|
|
254
|
+
let result = text.replace(/\r\n/g, "\n");
|
|
255
|
+
|
|
256
|
+
// Remove labeled untrusted metadata JSON blocks.
|
|
257
|
+
// Examples:
|
|
258
|
+
// Conversation info (untrusted metadata): ```json ... ```
|
|
259
|
+
// Sender (untrusted metadata): ```json ... ```
|
|
260
|
+
result = result.replace(
|
|
261
|
+
/(^|\n)[^\n]*\(untrusted metadata(?:,\s*for context)?\)\s*:\s*```json[\s\S]*?```(?=\n|$)/gi,
|
|
262
|
+
"\n"
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
// Remove transport/system hint lines that are not user utterances.
|
|
266
|
+
result = result.replace(/^\[System:[^\n]*\]\s*$/gim, "");
|
|
267
|
+
result = result.replace(/^\[message_id:\s*[^\]]+\]\s*$/gim, "");
|
|
268
|
+
|
|
269
|
+
// Collapse extra blank lines.
|
|
270
|
+
result = result.replace(/\n{3,}/g, "\n\n").trim();
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Keep the latest meaningful line from cleaned metadata-injected payload.
|
|
276
|
+
*/
|
|
277
|
+
function extractLatestUtteranceFromCleanText(text) {
|
|
278
|
+
if (!text || typeof text !== "string") return "";
|
|
279
|
+
const lines = text
|
|
280
|
+
.split("\n")
|
|
281
|
+
.map((line) => line.trim())
|
|
282
|
+
.filter(Boolean)
|
|
283
|
+
.filter((line) => !isMetadataOnlyText(line));
|
|
284
|
+
if (lines.length === 0) return "";
|
|
285
|
+
return lines[lines.length - 1];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function escapeRegex(text) {
|
|
289
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Prefix utterance with parsed sender name when available.
|
|
294
|
+
* This preserves who said it in multi-user group contexts.
|
|
295
|
+
*/
|
|
296
|
+
function attachSenderPrefix(utterance, senderName) {
|
|
297
|
+
const content = String(utterance || "").trim();
|
|
298
|
+
if (!content) return "";
|
|
299
|
+
const sender = String(senderName || "").trim();
|
|
300
|
+
if (!sender) return content;
|
|
301
|
+
|
|
302
|
+
const senderRegex = new RegExp(`^${escapeRegex(sender)}\\s*[::]`);
|
|
303
|
+
if (senderRegex.test(content)) return content;
|
|
304
|
+
return `${sender}: ${content}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
126
307
|
/**
|
|
127
308
|
* Normalize user message text before caching/storing.
|
|
128
309
|
* For channel-formatted payloads (e.g. Feishu), keep only the actual user utterance
|
|
@@ -135,6 +316,17 @@ function normalizeUserMessageContent(content) {
|
|
|
135
316
|
const normalized = String(text).replace(/\r\n/g, "\n").trim();
|
|
136
317
|
if (!normalized) return "";
|
|
137
318
|
|
|
319
|
+
const parsedIdentity = parseIdentityFromUntrustedMetadata(normalized);
|
|
320
|
+
|
|
321
|
+
// New relay payload format: strip injected metadata blocks first.
|
|
322
|
+
const strippedMetadata = stripInjectedMetadata(normalized);
|
|
323
|
+
if (strippedMetadata && strippedMetadata !== normalized) {
|
|
324
|
+
const latestUtterance = extractLatestUtteranceFromCleanText(strippedMetadata);
|
|
325
|
+
if (latestUtterance && !isMetadataOnlyText(latestUtterance)) {
|
|
326
|
+
return attachSenderPrefix(latestUtterance, parsedIdentity?.userName);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
138
330
|
// Feishu: preserve the final traceable tail block (contains platform user id/message_id).
|
|
139
331
|
const feishuTailBlock = extractFeishuTailBlock(normalized);
|
|
140
332
|
if (feishuTailBlock && !isMetadataOnlyText(feishuTailBlock)) {
|
|
@@ -168,7 +360,9 @@ function normalizeUserMessageContent(content) {
|
|
|
168
360
|
if (!isMetadataOnlyText(candidate)) return candidate;
|
|
169
361
|
}
|
|
170
362
|
|
|
171
|
-
return isMetadataOnlyText(normalized)
|
|
363
|
+
return isMetadataOnlyText(normalized)
|
|
364
|
+
? ""
|
|
365
|
+
: attachSenderPrefix(normalized, parsedIdentity?.userName);
|
|
172
366
|
}
|
|
173
367
|
|
|
174
368
|
/**
|
|
@@ -183,6 +377,125 @@ function normalizeAssistantMessageContent(content) {
|
|
|
183
377
|
return normalized;
|
|
184
378
|
}
|
|
185
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Parse JSON objects from markdown code fences after a specific label.
|
|
382
|
+
* Example:
|
|
383
|
+
* Sender (untrusted metadata):
|
|
384
|
+
* ```json
|
|
385
|
+
* { "id": "123" }
|
|
386
|
+
* ```
|
|
387
|
+
*/
|
|
388
|
+
function parseJsonFencesAfterLabel(text, labelPattern) {
|
|
389
|
+
if (!text || typeof text !== "string") return [];
|
|
390
|
+
|
|
391
|
+
const results = [];
|
|
392
|
+
const regex = new RegExp(`${labelPattern}\\s*:\\s*\`\`\`json\\s*([\\s\\S]*?)\\s*\`\`\``, "gi");
|
|
393
|
+
let match;
|
|
394
|
+
while ((match = regex.exec(text)) !== null) {
|
|
395
|
+
if (!match[1]) continue;
|
|
396
|
+
try {
|
|
397
|
+
const parsed = JSON.parse(match[1]);
|
|
398
|
+
if (parsed && typeof parsed === "object") {
|
|
399
|
+
results.push(parsed);
|
|
400
|
+
}
|
|
401
|
+
} catch (_) {
|
|
402
|
+
// Ignore malformed JSON blocks and continue matching others.
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return results;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Normalize potential user id values into non-empty strings.
|
|
410
|
+
*/
|
|
411
|
+
function normalizeParsedUserId(value) {
|
|
412
|
+
if (value === undefined || value === null) return "";
|
|
413
|
+
const normalized = String(value).trim();
|
|
414
|
+
return normalized || "";
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Extract user id from a display label like:
|
|
419
|
+
* "用户250389 (ou_xxx)" / "name (123456789)".
|
|
420
|
+
*/
|
|
421
|
+
function extractUserIdFromLabel(label) {
|
|
422
|
+
const text = normalizeParsedUserId(label);
|
|
423
|
+
if (!text) return "";
|
|
424
|
+
const match = text.match(/\(\s*((?:ou|on|u)_[A-Za-z0-9]+|\d{6,})\s*\)$/i);
|
|
425
|
+
return match?.[1] ? match[1] : "";
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Extract transport message id from current relay payload.
|
|
430
|
+
*/
|
|
431
|
+
function extractRelayMessageId(text) {
|
|
432
|
+
if (!text || typeof text !== "string") return "";
|
|
433
|
+
|
|
434
|
+
const conversationMetaList = parseJsonFencesAfterLabel(
|
|
435
|
+
text,
|
|
436
|
+
"Conversation info \\(untrusted metadata\\)"
|
|
437
|
+
);
|
|
438
|
+
for (let i = conversationMetaList.length - 1; i >= 0; i--) {
|
|
439
|
+
const conversationMeta = conversationMetaList[i];
|
|
440
|
+
const messageId =
|
|
441
|
+
normalizeParsedUserId(conversationMeta?.message_id) ||
|
|
442
|
+
normalizeParsedUserId(conversationMeta?.messageId);
|
|
443
|
+
if (messageId) return messageId;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const messageIdMatch = text.match(/\[message_id:\s*([^\]\n]+)\]/i);
|
|
447
|
+
if (messageIdMatch?.[1]) return messageIdMatch[1].trim();
|
|
448
|
+
|
|
449
|
+
return "";
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Parse identity from new "untrusted metadata" JSON blocks.
|
|
454
|
+
* Priority:
|
|
455
|
+
* 1) Sender (untrusted metadata).id
|
|
456
|
+
* 2) Conversation info (untrusted metadata).sender_id
|
|
457
|
+
*/
|
|
458
|
+
function parseIdentityFromUntrustedMetadata(text) {
|
|
459
|
+
if (!text || typeof text !== "string") return null;
|
|
460
|
+
|
|
461
|
+
const senderMetaList = parseJsonFencesAfterLabel(text, "Sender \\(untrusted metadata\\)");
|
|
462
|
+
for (let i = senderMetaList.length - 1; i >= 0; i--) {
|
|
463
|
+
const senderMeta = senderMetaList[i];
|
|
464
|
+
const userId =
|
|
465
|
+
normalizeParsedUserId(senderMeta?.id) ||
|
|
466
|
+
extractUserIdFromLabel(senderMeta?.label);
|
|
467
|
+
if (userId) {
|
|
468
|
+
return {
|
|
469
|
+
platform: null,
|
|
470
|
+
userId,
|
|
471
|
+
userName: senderMeta?.name || senderMeta?.username || senderMeta?.label || null,
|
|
472
|
+
source: "sender-untrusted-metadata-json",
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const conversationMetaList = parseJsonFencesAfterLabel(
|
|
478
|
+
text,
|
|
479
|
+
"Conversation info \\(untrusted metadata\\)"
|
|
480
|
+
);
|
|
481
|
+
for (let i = conversationMetaList.length - 1; i >= 0; i--) {
|
|
482
|
+
const conversationMeta = conversationMetaList[i];
|
|
483
|
+
const userId =
|
|
484
|
+
normalizeParsedUserId(conversationMeta?.sender_id) ||
|
|
485
|
+
normalizeParsedUserId(conversationMeta?.senderId);
|
|
486
|
+
if (userId) {
|
|
487
|
+
return {
|
|
488
|
+
platform: null,
|
|
489
|
+
userId,
|
|
490
|
+
userName: conversationMeta?.sender || null,
|
|
491
|
+
source: "conversation-untrusted-metadata-json",
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
186
499
|
/**
|
|
187
500
|
* Parse platform identity hints from channel-formatted message text.
|
|
188
501
|
* Returns null when no platform-specific id can be extracted.
|
|
@@ -190,6 +503,14 @@ function normalizeAssistantMessageContent(content) {
|
|
|
190
503
|
function parsePlatformIdentity(text) {
|
|
191
504
|
if (!text || typeof text !== "string") return null;
|
|
192
505
|
|
|
506
|
+
// New Discord format first:
|
|
507
|
+
// Sender (untrusted metadata): ```json { "id": "116..." } ```
|
|
508
|
+
// Conversation info (untrusted metadata): ```json { "sender_id": "116..." } ```
|
|
509
|
+
const metadataIdentity = parseIdentityFromUntrustedMetadata(text);
|
|
510
|
+
if (metadataIdentity?.userId) {
|
|
511
|
+
return metadataIdentity;
|
|
512
|
+
}
|
|
513
|
+
|
|
193
514
|
// Discord example:
|
|
194
515
|
// [from: huang yongqing (1470374017541079042)]
|
|
195
516
|
const discordFrom = text.match(/\[from:\s*([^\(\]\n]+?)\s*\((\d{6,})\)\]/i);
|
|
@@ -224,11 +545,32 @@ function parseAllPlatformUserIds(text) {
|
|
|
224
545
|
if (!text || typeof text !== "string") return [];
|
|
225
546
|
|
|
226
547
|
const ids = [];
|
|
548
|
+
let match;
|
|
549
|
+
|
|
550
|
+
// New format: "Sender (untrusted metadata)" JSON blocks.
|
|
551
|
+
const senderMetaList = parseJsonFencesAfterLabel(text, "Sender \\(untrusted metadata\\)");
|
|
552
|
+
for (const senderMeta of senderMetaList) {
|
|
553
|
+
const parsedId =
|
|
554
|
+
normalizeParsedUserId(senderMeta?.id) ||
|
|
555
|
+
extractUserIdFromLabel(senderMeta?.label);
|
|
556
|
+
if (parsedId) ids.push(parsedId);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// New format: "Conversation info (untrusted metadata)" JSON blocks.
|
|
560
|
+
const conversationMetaList = parseJsonFencesAfterLabel(
|
|
561
|
+
text,
|
|
562
|
+
"Conversation info \\(untrusted metadata\\)"
|
|
563
|
+
);
|
|
564
|
+
for (const conversationMeta of conversationMetaList) {
|
|
565
|
+
const parsedId =
|
|
566
|
+
normalizeParsedUserId(conversationMeta?.sender_id) ||
|
|
567
|
+
normalizeParsedUserId(conversationMeta?.senderId);
|
|
568
|
+
if (parsedId) ids.push(parsedId);
|
|
569
|
+
}
|
|
227
570
|
|
|
228
571
|
// Discord example:
|
|
229
572
|
// [from: huang yongqing (1470374017541079042)]
|
|
230
573
|
const discordRegex = /\[from:\s*[^\(\]\n]+?\s*\((\d{6,})\)\]/gi;
|
|
231
|
-
let match;
|
|
232
574
|
while ((match = discordRegex.exec(text)) !== null) {
|
|
233
575
|
if (match[1]) ids.push(match[1]);
|
|
234
576
|
}
|
|
@@ -266,6 +608,90 @@ function collectUniqueUserIdsFromMessages(messages, fallbackUserId) {
|
|
|
266
608
|
return Array.from(unique);
|
|
267
609
|
}
|
|
268
610
|
|
|
611
|
+
function extractUserNameFromLabel(label) {
|
|
612
|
+
const text = normalizeParsedUserId(label);
|
|
613
|
+
if (!text) return "";
|
|
614
|
+
const match = text.match(
|
|
615
|
+
/^(.*?)\s*\(\s*(?:(?:ou|on|u)_[A-Za-z0-9]+|\d{6,})\s*\)\s*$/i
|
|
616
|
+
);
|
|
617
|
+
return match?.[1] ? match[1].trim() : text;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Parse name-id pairs from supported relay payload formats.
|
|
622
|
+
*/
|
|
623
|
+
function parseNameIdPairsFromText(text) {
|
|
624
|
+
if (!text || typeof text !== "string") return [];
|
|
625
|
+
const pairs = [];
|
|
626
|
+
|
|
627
|
+
// New format: Sender (untrusted metadata)
|
|
628
|
+
const senderMetaList = parseJsonFencesAfterLabel(text, "Sender \\(untrusted metadata\\)");
|
|
629
|
+
for (const senderMeta of senderMetaList) {
|
|
630
|
+
const id =
|
|
631
|
+
normalizeParsedUserId(senderMeta?.id) ||
|
|
632
|
+
extractUserIdFromLabel(senderMeta?.label);
|
|
633
|
+
const name =
|
|
634
|
+
normalizeParsedUserId(senderMeta?.name) ||
|
|
635
|
+
normalizeParsedUserId(senderMeta?.username) ||
|
|
636
|
+
extractUserNameFromLabel(senderMeta?.label);
|
|
637
|
+
if (name && id) pairs.push([name, id]);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// New format: Conversation info (untrusted metadata)
|
|
641
|
+
const conversationMetaList = parseJsonFencesAfterLabel(
|
|
642
|
+
text,
|
|
643
|
+
"Conversation info \\(untrusted metadata\\)"
|
|
644
|
+
);
|
|
645
|
+
for (const conversationMeta of conversationMetaList) {
|
|
646
|
+
const id =
|
|
647
|
+
normalizeParsedUserId(conversationMeta?.sender_id) ||
|
|
648
|
+
normalizeParsedUserId(conversationMeta?.senderId);
|
|
649
|
+
const name = normalizeParsedUserId(conversationMeta?.sender);
|
|
650
|
+
if (name && id) pairs.push([name, id]);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Old Discord: [from: name (id)]
|
|
654
|
+
const discordRegex = /\[from:\s*([^\(\]\n]+?)\s*\((\d{6,})\)\]/gi;
|
|
655
|
+
let match;
|
|
656
|
+
while ((match = discordRegex.exec(text)) !== null) {
|
|
657
|
+
const name = normalizeParsedUserId(match[1]);
|
|
658
|
+
const id = normalizeParsedUserId(match[2]);
|
|
659
|
+
if (name && id) pairs.push([name, id]);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Old Feishu: [Feishu ...:ou_xxx ...] name:
|
|
663
|
+
const feishuRegex = /\[Feishu[^\]]*:((?:ou|on|u)_[A-Za-z0-9]+)[^\]]*\]\s*([^:\n]+):/gi;
|
|
664
|
+
while ((match = feishuRegex.exec(text)) !== null) {
|
|
665
|
+
const id = normalizeParsedUserId(match[1]);
|
|
666
|
+
const name = normalizeParsedUserId(match[2]);
|
|
667
|
+
if (name && id) pairs.push([name, id]);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return pairs;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Collect distinct name->id mapping from all messages.
|
|
675
|
+
*/
|
|
676
|
+
function collectNameIdMapFromMessages(messages) {
|
|
677
|
+
const map = {};
|
|
678
|
+
|
|
679
|
+
if (!Array.isArray(messages)) return map;
|
|
680
|
+
|
|
681
|
+
for (const msg of messages) {
|
|
682
|
+
if (!msg) continue;
|
|
683
|
+
const sourceText = msg.rawContent !== undefined ? msg.rawContent : msg.content;
|
|
684
|
+
const text = typeof sourceText === "string" ? sourceText : extractText(sourceText);
|
|
685
|
+
const pairs = parseNameIdPairsFromText(text);
|
|
686
|
+
for (const [name, id] of pairs) {
|
|
687
|
+
if (!name || !id) continue;
|
|
688
|
+
map[name] = id;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return map;
|
|
693
|
+
}
|
|
694
|
+
|
|
269
695
|
/**
|
|
270
696
|
* Get latest user message text from cached messages.
|
|
271
697
|
*/
|
|
@@ -284,7 +710,7 @@ function getLatestUserMessageText(messages) {
|
|
|
284
710
|
* Resolve request identity with fallback:
|
|
285
711
|
* platform user id -> configured user id -> "openclaw-user"
|
|
286
712
|
*/
|
|
287
|
-
function resolveRequestIdentity(promptText, cfg
|
|
713
|
+
function resolveRequestIdentity(promptText, cfg) {
|
|
288
714
|
const parsed = parsePlatformIdentity(promptText);
|
|
289
715
|
if (parsed?.userId) {
|
|
290
716
|
return {
|
|
@@ -304,15 +730,6 @@ function resolveRequestIdentity(promptText, cfg, ctx) {
|
|
|
304
730
|
};
|
|
305
731
|
}
|
|
306
732
|
|
|
307
|
-
if (ctx?.userId) {
|
|
308
|
-
return {
|
|
309
|
-
userId: ctx.userId,
|
|
310
|
-
userName: null,
|
|
311
|
-
platform: null,
|
|
312
|
-
source: "ctx-user-id",
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
|
|
316
733
|
return {
|
|
317
734
|
userId: "openclaw-user",
|
|
318
735
|
userName: null,
|
|
@@ -507,7 +924,7 @@ async function retrieveMemory(prompt, cfg, ctx, log) {
|
|
|
507
924
|
if (log?.info) {
|
|
508
925
|
log.info(`[Memory Plugin] Recall request URL: ${url}`);
|
|
509
926
|
}
|
|
510
|
-
const identity = resolveRequestIdentity(prompt, cfg
|
|
927
|
+
const identity = resolveRequestIdentity(prompt, cfg);
|
|
511
928
|
const userId = sanitizeUserId(identity.userId);
|
|
512
929
|
const payload = {
|
|
513
930
|
query: prompt,
|
|
@@ -554,7 +971,7 @@ async function retrieveMemory(prompt, cfg, ctx, log) {
|
|
|
554
971
|
/**
|
|
555
972
|
* Add memories to the API
|
|
556
973
|
*/
|
|
557
|
-
async function addMemory(messages, cfg, ctx, log) {
|
|
974
|
+
async function addMemory(messages, cfg, ctx, log, explicitSessionId) {
|
|
558
975
|
const baseUrl = cfg.baseUrl || process.env.HUMAN_LIKE_MEM_BASE_URL;
|
|
559
976
|
const apiKey = cfg.apiKey || process.env.HUMAN_LIKE_MEM_API_KEY;
|
|
560
977
|
|
|
@@ -564,9 +981,9 @@ async function addMemory(messages, cfg, ctx, log) {
|
|
|
564
981
|
if (log?.info) {
|
|
565
982
|
log.info(`[Memory Plugin] Add-memory request URL: ${url}`);
|
|
566
983
|
}
|
|
567
|
-
const sessionId = resolveSessionId(ctx, null) || `session-${Date.now()}`;
|
|
984
|
+
const sessionId = explicitSessionId || resolveSessionId(ctx, null) || `session-${Date.now()}`;
|
|
568
985
|
const latestUserText = getLatestUserMessageText(messages);
|
|
569
|
-
const identity = resolveRequestIdentity(latestUserText, cfg
|
|
986
|
+
const identity = resolveRequestIdentity(latestUserText, cfg);
|
|
570
987
|
const userId = sanitizeUserId(identity.userId);
|
|
571
988
|
const metadataUserIds = (() => {
|
|
572
989
|
const parsed = collectUniqueUserIdsFromMessages(messages, null)
|
|
@@ -577,6 +994,13 @@ async function addMemory(messages, cfg, ctx, log) {
|
|
|
577
994
|
}
|
|
578
995
|
return [userId];
|
|
579
996
|
})();
|
|
997
|
+
const nameIdMap = (() => {
|
|
998
|
+
const parsed = collectNameIdMapFromMessages(messages);
|
|
999
|
+
if (identity?.userName && userId && !parsed[identity.userName]) {
|
|
1000
|
+
parsed[identity.userName] = userId;
|
|
1001
|
+
}
|
|
1002
|
+
return parsed;
|
|
1003
|
+
})();
|
|
580
1004
|
const agentId = cfg.agentId || ctx?.agentId || "main";
|
|
581
1005
|
|
|
582
1006
|
const payload = {
|
|
@@ -594,6 +1018,7 @@ async function addMemory(messages, cfg, ctx, log) {
|
|
|
594
1018
|
metadata: JSON.stringify({
|
|
595
1019
|
user_ids: metadataUserIds,
|
|
596
1020
|
agent_ids: [agentId],
|
|
1021
|
+
name_id_map: nameIdMap,
|
|
597
1022
|
session_id: sessionId,
|
|
598
1023
|
scenario: cfg.scenario || "openclaw-plugin",
|
|
599
1024
|
}),
|
|
@@ -603,7 +1028,7 @@ async function addMemory(messages, cfg, ctx, log) {
|
|
|
603
1028
|
|
|
604
1029
|
if (log?.debug) {
|
|
605
1030
|
log.debug(
|
|
606
|
-
`[Memory Plugin] add/message payload: user_id=${userId}, agent_id=${agentId}, conversation_id=${sessionId}, metadata.user_ids=${JSON.stringify(metadataUserIds)}, metadata.agent_ids=${JSON.stringify([agentId])}`
|
|
1031
|
+
`[Memory Plugin] add/message payload: user_id=${userId}, agent_id=${agentId}, conversation_id=${sessionId}, metadata.user_ids=${JSON.stringify(metadataUserIds)}, metadata.agent_ids=${JSON.stringify([agentId])}, metadata.name_id_map=${JSON.stringify(nameIdMap)}`
|
|
607
1032
|
);
|
|
608
1033
|
}
|
|
609
1034
|
|
|
@@ -753,8 +1178,8 @@ function formatMemoriesForContext(memories, options = {}) {
|
|
|
753
1178
|
* @param {number} maxTurns - Maximum number of turns to extract
|
|
754
1179
|
* @returns {Array} Recent messages
|
|
755
1180
|
*/
|
|
756
|
-
function pickRecentMessages(messages, maxTurns = 10) {
|
|
757
|
-
if (!messages || messages.length === 0) return [];
|
|
1181
|
+
function pickRecentMessages(messages, maxTurns = 10) {
|
|
1182
|
+
if (!messages || messages.length === 0) return [];
|
|
758
1183
|
|
|
759
1184
|
const result = [];
|
|
760
1185
|
let turnCount = 0;
|
|
@@ -769,7 +1194,7 @@ function pickRecentMessages(messages, maxTurns = 10) {
|
|
|
769
1194
|
if (role === "system") continue;
|
|
770
1195
|
|
|
771
1196
|
// Count a turn when we see a user message after an assistant message
|
|
772
|
-
if (role === "user" && lastRole
|
|
1197
|
+
if (role === "user" && isAssistantLikeRole(lastRole)) {
|
|
773
1198
|
turnCount++;
|
|
774
1199
|
}
|
|
775
1200
|
|
|
@@ -795,9 +1220,230 @@ function pickRecentMessages(messages, maxTurns = 10) {
|
|
|
795
1220
|
lastRole = role;
|
|
796
1221
|
}
|
|
797
1222
|
|
|
798
|
-
return result;
|
|
1223
|
+
return result;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function buildV2ConversationMessage(msg) {
|
|
1227
|
+
if (!msg) return null;
|
|
1228
|
+
|
|
1229
|
+
const role = normalizeCacheRole(msg.role);
|
|
1230
|
+
if (role === "system") return null;
|
|
1231
|
+
if (role !== "user" && role !== "assistant" && role !== "tool") return null;
|
|
1232
|
+
|
|
1233
|
+
const rawSource = msg.rawContent !== undefined ? msg.rawContent : msg.content;
|
|
1234
|
+
const rawContent = typeof rawSource === "string" ? rawSource : extractText(rawSource);
|
|
1235
|
+
const content =
|
|
1236
|
+
typeof msg.content === "string"
|
|
1237
|
+
? msg.content
|
|
1238
|
+
: extractText(msg.content);
|
|
1239
|
+
const toolCalls = role === "assistant" ? getMessageToolCalls(msg) : [];
|
|
1240
|
+
const toolCallId = role === "tool" ? getToolResultCallId(msg) : null;
|
|
1241
|
+
const toolName = role === "tool" ? getToolResultName(msg) : undefined;
|
|
1242
|
+
const hasToolContext =
|
|
1243
|
+
(role === "assistant" && toolCalls.length > 0) ||
|
|
1244
|
+
(role === "tool" && (!!toolCallId || !!toolName));
|
|
1245
|
+
|
|
1246
|
+
if (!content && !hasToolContext) return null;
|
|
1247
|
+
|
|
1248
|
+
const result = {
|
|
1249
|
+
role,
|
|
1250
|
+
content: content || "",
|
|
1251
|
+
rawContent: rawContent || undefined,
|
|
1252
|
+
};
|
|
1253
|
+
|
|
1254
|
+
if (toolCalls.length > 0) result.tool_calls = toolCalls;
|
|
1255
|
+
if (toolCallId) result.tool_call_id = toolCallId;
|
|
1256
|
+
if (toolName) result.name = toolName;
|
|
1257
|
+
|
|
1258
|
+
return result;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function pickRecentContextMessagesV2(messages, maxTurns = 10) {
|
|
1262
|
+
if (!messages || messages.length === 0) return [];
|
|
1263
|
+
|
|
1264
|
+
const result = [];
|
|
1265
|
+
let turnCount = 0;
|
|
1266
|
+
let lastRole = null;
|
|
1267
|
+
|
|
1268
|
+
for (let i = messages.length - 1; i >= 0 && turnCount < maxTurns; i--) {
|
|
1269
|
+
const msg = messages[i];
|
|
1270
|
+
const role = normalizeCacheRole(msg?.role);
|
|
1271
|
+
|
|
1272
|
+
if (role === "system") continue;
|
|
1273
|
+
|
|
1274
|
+
if (role === "user" && isAssistantLikeRole(lastRole)) {
|
|
1275
|
+
turnCount++;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
if (turnCount >= maxTurns) break;
|
|
1279
|
+
|
|
1280
|
+
const contextMessage = buildV2ConversationMessage(msg);
|
|
1281
|
+
if (contextMessage) {
|
|
1282
|
+
result.unshift(contextMessage);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
lastRole = role;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
return result;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function stripToolMessagesForV1(messages) {
|
|
1292
|
+
if (!Array.isArray(messages)) return [];
|
|
1293
|
+
|
|
1294
|
+
return messages
|
|
1295
|
+
.filter((msg) => msg && (msg.role === "user" || msg.role === "assistant"))
|
|
1296
|
+
.filter((msg) => String(msg.content || "").trim())
|
|
1297
|
+
.map((msg) => ({
|
|
1298
|
+
role: msg.role,
|
|
1299
|
+
content: msg.content,
|
|
1300
|
+
rawContent: msg.rawContent,
|
|
1301
|
+
}));
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Extract tool calls from cached messages for the v2 context protocol.
|
|
1306
|
+
* @param {Array} messages - All cached messages
|
|
1307
|
+
* @returns {Array} Tool call records
|
|
1308
|
+
*/
|
|
1309
|
+
function extractToolCalls(messages) {
|
|
1310
|
+
if (!messages || messages.length === 0) return [];
|
|
1311
|
+
|
|
1312
|
+
const calls = [];
|
|
1313
|
+
for (const msg of messages) {
|
|
1314
|
+
if (msg.role === "assistant") {
|
|
1315
|
+
const toolCalls = getMessageToolCalls(msg);
|
|
1316
|
+
for (const tc of toolCalls) {
|
|
1317
|
+
calls.push({
|
|
1318
|
+
tool_name: tc.function?.name || tc.name || "unknown",
|
|
1319
|
+
arguments: tc.function?.arguments || tc.arguments || {},
|
|
1320
|
+
call_id: tc.id || null,
|
|
1321
|
+
result: null,
|
|
1322
|
+
success: null,
|
|
1323
|
+
duration_ms: null,
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
if (msg.role === "tool") {
|
|
1329
|
+
const toolCallId = getToolResultCallId(msg);
|
|
1330
|
+
const toolName = getToolResultName(msg);
|
|
1331
|
+
const match = calls.find((call) =>
|
|
1332
|
+
(toolCallId && call.call_id === toolCallId) ||
|
|
1333
|
+
(!toolCallId && toolName && call.tool_name === toolName && call.result == null)
|
|
1334
|
+
);
|
|
1335
|
+
if (match) {
|
|
1336
|
+
const resultText = extractText(msg.rawContent !== undefined ? msg.rawContent : msg.content);
|
|
1337
|
+
match.result = truncate(resultText, 2000);
|
|
1338
|
+
match.success = !isErrorResult(resultText);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
return calls;
|
|
799
1344
|
}
|
|
800
1345
|
|
|
1346
|
+
/**
|
|
1347
|
+
* Check if text looks like an error result
|
|
1348
|
+
* @param {string} text - Result text
|
|
1349
|
+
* @returns {boolean}
|
|
1350
|
+
*/
|
|
1351
|
+
function isErrorResult(text) {
|
|
1352
|
+
if (!text) return false;
|
|
1353
|
+
const lower = String(text).toLowerCase();
|
|
1354
|
+
return lower.includes("error") || lower.includes("exception") ||
|
|
1355
|
+
lower.includes("failed") || lower.includes("traceback") ||
|
|
1356
|
+
lower.includes("enoent") || lower.includes("permission denied");
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* Collect context blocks from messages (v2 protocol)
|
|
1361
|
+
* @param {Array} messages - All cached messages
|
|
1362
|
+
* @param {Object} cfg - Configuration
|
|
1363
|
+
* @returns {Array} Context blocks
|
|
1364
|
+
*/
|
|
1365
|
+
function collectContextBlocks(messages, cfg) {
|
|
1366
|
+
const blocks = [];
|
|
1367
|
+
|
|
1368
|
+
const conversationMsgs =
|
|
1369
|
+
cfg.captureToolCalls === false
|
|
1370
|
+
? pickRecentMessages(messages, cfg.maxTurnsToStore || 10)
|
|
1371
|
+
: pickRecentContextMessagesV2(messages, cfg.maxTurnsToStore || 10);
|
|
1372
|
+
if (conversationMsgs.length > 0) {
|
|
1373
|
+
blocks.push({
|
|
1374
|
+
type: "conversation",
|
|
1375
|
+
data: { messages: conversationMsgs },
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
return blocks;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* Add context via v2 protocol
|
|
1384
|
+
*/
|
|
1385
|
+
async function addContextV2(contextBlocks, cfg, ctx, log, sessionId) {
|
|
1386
|
+
const baseUrl = cfg.baseUrl || process.env.HUMAN_LIKE_MEM_BASE_URL;
|
|
1387
|
+
const apiKey = cfg.apiKey || process.env.HUMAN_LIKE_MEM_API_KEY;
|
|
1388
|
+
|
|
1389
|
+
if (!apiKey) return;
|
|
1390
|
+
|
|
1391
|
+
const url = `${baseUrl}/api/plugin/v2/add/context`;
|
|
1392
|
+
const latestUserText = getLatestUserMessageText(
|
|
1393
|
+
contextBlocks.find((block) => block.type === "conversation")?.data?.messages || []
|
|
1394
|
+
);
|
|
1395
|
+
const identity = resolveRequestIdentity(latestUserText, cfg);
|
|
1396
|
+
const userId = sanitizeUserId(identity.userId);
|
|
1397
|
+
const agentId = cfg.agentId || ctx?.agentId || "main";
|
|
1398
|
+
|
|
1399
|
+
const payload = {
|
|
1400
|
+
user_id: userId,
|
|
1401
|
+
conversation_id: sessionId,
|
|
1402
|
+
agent_id: agentId,
|
|
1403
|
+
tags: cfg.tags || ["openclaw"],
|
|
1404
|
+
async_mode: true,
|
|
1405
|
+
protocol_version: "2.0",
|
|
1406
|
+
context_blocks: contextBlocks,
|
|
1407
|
+
};
|
|
1408
|
+
|
|
1409
|
+
if (log?.debug) {
|
|
1410
|
+
log.debug(
|
|
1411
|
+
`[Memory Plugin] v2 add/context: blocks=${contextBlocks.length}, types=${contextBlocks.map((block) => block.type).join(",")}`
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
try {
|
|
1416
|
+
const result = await httpRequest(url, {
|
|
1417
|
+
method: "POST",
|
|
1418
|
+
headers: {
|
|
1419
|
+
"Content-Type": "application/json",
|
|
1420
|
+
"x-api-key": apiKey,
|
|
1421
|
+
"x-request-id": ctx?.requestId || `openclaw-${Date.now()}`,
|
|
1422
|
+
"x-plugin-version": PLUGIN_VERSION,
|
|
1423
|
+
"x-client-type": "plugin",
|
|
1424
|
+
},
|
|
1425
|
+
body: JSON.stringify(payload),
|
|
1426
|
+
}, cfg, log);
|
|
1427
|
+
|
|
1428
|
+
const memoryCount = result?.memories_count || 0;
|
|
1429
|
+
if (log?.info) {
|
|
1430
|
+
log.info(
|
|
1431
|
+
`[Memory Plugin] v2 add/context success: ${memoryCount} memories from ${result?.blocks_processed || 0} blocks`
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
return result;
|
|
1435
|
+
} catch (error) {
|
|
1436
|
+
if (log?.warn) {
|
|
1437
|
+
log.warn(`[Memory Plugin] v2 add/context failed, falling back to v1: ${error.message}`);
|
|
1438
|
+
}
|
|
1439
|
+
const conversationBlock = contextBlocks.find((block) => block.type === "conversation");
|
|
1440
|
+
if (conversationBlock) {
|
|
1441
|
+
return await addMemory(stripToolMessagesForV1(conversationBlock.data.messages), cfg, ctx, log, sessionId);
|
|
1442
|
+
}
|
|
1443
|
+
throw error;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
801
1447
|
/**
|
|
802
1448
|
* Check if conversation has enough turns to be worth storing
|
|
803
1449
|
* @param {Array} messages - Messages to check
|
|
@@ -817,7 +1463,7 @@ function isConversationWorthStoring(messages, cfg) {
|
|
|
817
1463
|
if (msg.role === "system") continue;
|
|
818
1464
|
|
|
819
1465
|
if (msg.role === "user") {
|
|
820
|
-
if (lastRole
|
|
1466
|
+
if (isAssistantLikeRole(lastRole)) {
|
|
821
1467
|
turns++;
|
|
822
1468
|
}
|
|
823
1469
|
}
|
|
@@ -852,13 +1498,39 @@ function getSessionCache(sessionId) {
|
|
|
852
1498
|
*/
|
|
853
1499
|
function addToSessionCache(sessionId, message) {
|
|
854
1500
|
const cache = getSessionCache(sessionId);
|
|
855
|
-
|
|
1501
|
+
const now = Date.now();
|
|
1502
|
+
const incoming = {
|
|
1503
|
+
...message,
|
|
1504
|
+
cachedAt: now,
|
|
1505
|
+
};
|
|
1506
|
+
|
|
1507
|
+
const prev = cache.messages.length > 0 ? cache.messages[cache.messages.length - 1] : null;
|
|
1508
|
+
if (prev) {
|
|
1509
|
+
const sameRole = prev.role === incoming.role;
|
|
1510
|
+
const sameMessageId =
|
|
1511
|
+
incoming.messageId &&
|
|
1512
|
+
prev.messageId &&
|
|
1513
|
+
String(incoming.messageId) === String(prev.messageId);
|
|
1514
|
+
const sameContent =
|
|
1515
|
+
sameRole &&
|
|
1516
|
+
String(prev.content || "") === String(incoming.content || "") &&
|
|
1517
|
+
String(prev.rawContent || "") === String(incoming.rawContent || "");
|
|
1518
|
+
const withinWindow = now - (prev.cachedAt || 0) <= CACHE_DEDUP_WINDOW_MS;
|
|
1519
|
+
|
|
1520
|
+
// Dedup duplicate hook triggers for the same relay message.
|
|
1521
|
+
if ((sameRole && sameMessageId) || (sameContent && withinWindow)) {
|
|
1522
|
+
cache.lastActivity = now;
|
|
1523
|
+
return cache;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
cache.messages.push(incoming);
|
|
856
1528
|
cache.lastActivity = Date.now();
|
|
857
1529
|
|
|
858
1530
|
// Count turns (user message after assistant = new turn)
|
|
859
|
-
if (
|
|
1531
|
+
if (incoming.role === "user" && cache.messages.length > 1) {
|
|
860
1532
|
const prevMsg = cache.messages[cache.messages.length - 2];
|
|
861
|
-
if (prevMsg && prevMsg.role
|
|
1533
|
+
if (prevMsg && isAssistantLikeRole(prevMsg.role)) {
|
|
862
1534
|
cache.turnCount++;
|
|
863
1535
|
}
|
|
864
1536
|
}
|
|
@@ -970,6 +1642,15 @@ async function flushSession(sessionId, cfg, ctx, log) {
|
|
|
970
1642
|
if (log?.info) {
|
|
971
1643
|
log.info(`[Memory Plugin] Flushing session ${sessionId}: ${messagesToSave.length} messages, ${cache.turnCount} turns`);
|
|
972
1644
|
}
|
|
1645
|
+
|
|
1646
|
+
if (cfg.useV2Protocol !== false) {
|
|
1647
|
+
const contextBlocks = collectContextBlocks(cache.messages, cfg);
|
|
1648
|
+
if (contextBlocks.length > 0) {
|
|
1649
|
+
await addContextV2(contextBlocks, cfg, ctx, log, sessionId);
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
973
1654
|
await addMemory(messagesToSave, cfg, ctx, log, sessionId);
|
|
974
1655
|
} catch (error) {
|
|
975
1656
|
if (log?.warn) {
|
|
@@ -1021,8 +1702,14 @@ function registerPlugin(api) {
|
|
|
1021
1702
|
const sessionId = resolveSessionId(ctx, event) || `session-${Date.now()}`;
|
|
1022
1703
|
const userContent = normalizeUserMessageContent(prompt);
|
|
1023
1704
|
const rawUserContent = stripPrependedPrompt(prompt);
|
|
1705
|
+
const messageId = extractRelayMessageId(rawUserContent || prompt);
|
|
1024
1706
|
if (userContent) {
|
|
1025
|
-
addToSessionCache(sessionId, {
|
|
1707
|
+
addToSessionCache(sessionId, {
|
|
1708
|
+
role: "user",
|
|
1709
|
+
content: userContent,
|
|
1710
|
+
rawContent: rawUserContent,
|
|
1711
|
+
messageId: messageId || undefined,
|
|
1712
|
+
});
|
|
1026
1713
|
}
|
|
1027
1714
|
|
|
1028
1715
|
try {
|
|
@@ -1070,22 +1757,28 @@ function registerPlugin(api) {
|
|
|
1070
1757
|
return;
|
|
1071
1758
|
}
|
|
1072
1759
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
const content = normalizeAssistantMessageContent(assistantContent);
|
|
1076
|
-
const rawContent = extractText(assistantContent);
|
|
1077
|
-
if (content) {
|
|
1078
|
-
addToSessionCache(sessionId, { role: "assistant", content, rawContent: rawContent || undefined });
|
|
1079
|
-
}
|
|
1080
|
-
} else if (event?.messages?.length) {
|
|
1760
|
+
if (event?.messages?.length) {
|
|
1761
|
+
const pending = [];
|
|
1081
1762
|
for (let i = event.messages.length - 1; i >= 0; i--) {
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1763
|
+
const role = event.messages[i].role;
|
|
1764
|
+
if (role === "system") continue;
|
|
1765
|
+
if (role === "user") break;
|
|
1766
|
+
|
|
1767
|
+
const cacheMessage = buildCacheMessageFromTranscript(event.messages[i]);
|
|
1768
|
+
if (cacheMessage) pending.unshift(cacheMessage);
|
|
1769
|
+
}
|
|
1770
|
+
for (const cacheMessage of pending) {
|
|
1771
|
+
addToSessionCache(sessionId, cacheMessage);
|
|
1772
|
+
}
|
|
1773
|
+
} else {
|
|
1774
|
+
const assistantContent = event?.response || event?.result;
|
|
1775
|
+
if (assistantContent) {
|
|
1776
|
+
const cacheMessage = buildCacheMessageFromTranscript({
|
|
1777
|
+
role: "assistant",
|
|
1778
|
+
content: assistantContent,
|
|
1779
|
+
});
|
|
1780
|
+
if (cacheMessage) {
|
|
1781
|
+
addToSessionCache(sessionId, cacheMessage);
|
|
1089
1782
|
}
|
|
1090
1783
|
}
|
|
1091
1784
|
}
|
|
@@ -1153,8 +1846,14 @@ function createHooksPlugin(config) {
|
|
|
1153
1846
|
const sessionId = resolveSessionId(ctx, event) || `session-${Date.now()}`;
|
|
1154
1847
|
const userContent = normalizeUserMessageContent(prompt);
|
|
1155
1848
|
const rawUserContent = stripPrependedPrompt(prompt);
|
|
1849
|
+
const messageId = extractRelayMessageId(rawUserContent || prompt);
|
|
1156
1850
|
if (userContent) {
|
|
1157
|
-
addToSessionCache(sessionId, {
|
|
1851
|
+
addToSessionCache(sessionId, {
|
|
1852
|
+
role: "user",
|
|
1853
|
+
content: userContent,
|
|
1854
|
+
rawContent: rawUserContent,
|
|
1855
|
+
messageId: messageId || undefined,
|
|
1856
|
+
});
|
|
1158
1857
|
}
|
|
1159
1858
|
|
|
1160
1859
|
try {
|
|
@@ -1219,37 +1918,37 @@ function createHooksPlugin(config) {
|
|
|
1219
1918
|
const cache = getSessionCache(sessionId);
|
|
1220
1919
|
if (cache.messages.length === 0) {
|
|
1221
1920
|
for (const msg of event.messages) {
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
: normalizeAssistantMessageContent(msg.content);
|
|
1226
|
-
const rawSource = msg.role === "user"
|
|
1227
|
-
? stripPrependedPrompt(msg.content)
|
|
1228
|
-
: extractText(msg.content);
|
|
1229
|
-
if (content) {
|
|
1230
|
-
addToSessionCache(sessionId, { role: msg.role, content, rawContent: rawSource || undefined });
|
|
1921
|
+
const cacheMessage = buildCacheMessageFromTranscript(msg);
|
|
1922
|
+
if (cacheMessage) {
|
|
1923
|
+
addToSessionCache(sessionId, cacheMessage);
|
|
1231
1924
|
}
|
|
1232
1925
|
}
|
|
1233
1926
|
} else {
|
|
1234
|
-
//
|
|
1927
|
+
// Add trailing assistant/tool messages from the latest completion.
|
|
1928
|
+
const pending = [];
|
|
1235
1929
|
for (let i = event.messages.length - 1; i >= 0; i--) {
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1930
|
+
const msg = event.messages[i];
|
|
1931
|
+
if (msg.role === "system") continue;
|
|
1932
|
+
if (msg.role === "user") break;
|
|
1933
|
+
|
|
1934
|
+
const cacheMessage = buildCacheMessageFromTranscript(msg);
|
|
1935
|
+
if (cacheMessage) {
|
|
1936
|
+
pending.unshift(cacheMessage);
|
|
1243
1937
|
}
|
|
1244
1938
|
}
|
|
1939
|
+
for (const cacheMessage of pending) {
|
|
1940
|
+
addToSessionCache(sessionId, cacheMessage);
|
|
1941
|
+
}
|
|
1245
1942
|
}
|
|
1246
1943
|
} else {
|
|
1247
1944
|
const assistantContent = event?.response || event?.result;
|
|
1248
1945
|
if (assistantContent) {
|
|
1249
|
-
const
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1946
|
+
const cacheMessage = buildCacheMessageFromTranscript({
|
|
1947
|
+
role: "assistant",
|
|
1948
|
+
content: assistantContent,
|
|
1949
|
+
});
|
|
1950
|
+
if (cacheMessage) {
|
|
1951
|
+
addToSessionCache(sessionId, cacheMessage);
|
|
1253
1952
|
}
|
|
1254
1953
|
}
|
|
1255
1954
|
}
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanlikememory/human-like-mem",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.13",
|
|
4
4
|
"description": "Long-term memory plugin for OpenClaw - AI Social Memory Integration",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"jest": "^29.7.0",
|
|
51
51
|
"prettier": "^3.2.0"
|
|
52
52
|
},
|
|
53
|
-
"publishConfig": {
|
|
54
|
-
"access": "public"
|
|
55
|
-
}
|
|
56
|
-
}
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
}
|
|
56
|
+
}
|