@bsbofmusic/openclaw-memory-layer2 0.2.0 → 0.2.2
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 +45 -112
- package/hindsight.js +43 -7
- package/index.js +187 -55
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,136 +1,69 @@
|
|
|
1
1
|
# @bsbofmusic/openclaw-memory-layer2
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@bsbofmusic/openclaw-memory-layer2)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
> [English](#english) | [中文](#中文)
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
---
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
- **Layer 2** (Raw Facts): memos PostgreSQL + 本 MCP — 原话/细节/承诺/上下文
|
|
10
|
+
<a name="english"></a>
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
## English
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
### Introduction
|
|
15
|
+
**OpenClaw Memory Layer2** is a production-grade Long-term Memory MCP (Model Context Protocol) Server designed for the OpenClaw ecosystem. It bridges the gap between raw conversation logs and high-precision retrieval by combining **Hindsight** (Semantic Recall) and **PostgreSQL Memos** (Hard Evidence Verification).
|
|
15
16
|
|
|
16
|
-
###
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
### Core Features
|
|
18
|
+
- **Hindsight-First, Memos-as-Judge**: Uses Hindsight for broad semantic association while enforcing strict entity alignment via Memos to prevent "hallucinated recall".
|
|
19
|
+
- **Hybrid Retrieval**: Simultaneous Keyword + Vector (Cosine Similarity) search with weighted boosting for high-recall Chinese matching.
|
|
20
|
+
- **Production Readiness**: Built-in PM2 support, bounded timeouts for external services, and automated ingest pipelines.
|
|
21
|
+
- **Clean Ingest**: Intelligent filtering of tool logs, system events, and meta-noise to keep the memory bank pure.
|
|
20
22
|
|
|
21
|
-
###
|
|
23
|
+
### Quick Start
|
|
22
24
|
```bash
|
|
23
|
-
|
|
24
|
-
openclaw-memory-layer2
|
|
25
|
-
```
|
|
25
|
+
# Install
|
|
26
|
+
npm install @bsbofmusic/openclaw-memory-layer2
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
docker run bsbofmusic/openclaw-memory-layer2
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
## 环境变量
|
|
33
|
-
|
|
34
|
-
| 变量 | 默认值 | 说明 |
|
|
35
|
-
|------|--------|------|
|
|
36
|
-
| `OPENCLAW_CONFIG` | `/var/lib/openclaw/.openclaw/openclaw.json` | 默认从这里读取 OpenClaw memorySearch 配置 |
|
|
37
|
-
| `LAYER2_EMBED_API_KEY` | *(可选 override)* | 显式覆盖 embedding API key |
|
|
38
|
-
| `LAYER2_EMBED_BASE_URL` | *(可选 override)* | 显式覆盖 embedding endpoint |
|
|
39
|
-
| `LAYER2_EMBED_MODEL` | *(可选 override)* | 显式覆盖 embedding model |
|
|
40
|
-
| `MEMOS_PG_HOST` | `127.0.0.1` | PostgreSQL 主机 |
|
|
41
|
-
| `MEMOS_PG_PORT` | `5432` | PostgreSQL 端口 |
|
|
42
|
-
| `MEMOS_PG_DB` | `memos` | 数据库名 |
|
|
43
|
-
| `MEMOS_PG_USER` | `memos` | 数据库用户 |
|
|
44
|
-
| `MEMOS_PG_PASSWORD` | `memos_local_20260312` | 数据库密码 |
|
|
45
|
-
| `LOG_LEVEL` | `info` | error / info / debug |
|
|
46
|
-
| `WORKSPACE` | `/var/lib/openclaw/.openclaw/workspace` | 工作区路径 |
|
|
47
|
-
|
|
48
|
-
## MCP 工具列表
|
|
49
|
-
|
|
50
|
-
| 工具 | 说明 |
|
|
51
|
-
|------|------|
|
|
52
|
-
| `semantic_search` | 复用 OpenClaw memorySearch embedding 配置 → cosine similarity 语义搜索 |
|
|
53
|
-
| `query_memos` | 结构化 SELECT(只读),支持参数化查询 |
|
|
54
|
-
| `get_memos_stats` | 统计:总数、private/public、24h活跃 |
|
|
55
|
-
| `trigger_ingest` | 触发 session → memos ingest |
|
|
56
|
-
| `memory_layer2_info` | Layer1/Layer2 分工文档 |
|
|
57
|
-
| `layer2_ensure` | Bootstrap 自检 |
|
|
58
|
-
| `layer2_doctor` | 完整诊断 + 修复建议 |
|
|
59
|
-
| `hindsight_health` | 检查 Hindsight 服务是否可达 |
|
|
60
|
-
| `layer2_answer` | Hindsight + memos 联合归纳回答入口 |
|
|
61
|
-
| `layer2_version` | 版本信息 |
|
|
62
|
-
| `layer2_list_commands` | 工具自发现 |
|
|
63
|
-
|
|
64
|
-
## 验证命令
|
|
28
|
+
# Run Diagnostics
|
|
29
|
+
npm run doctor
|
|
65
30
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
node doctor.js
|
|
69
|
-
|
|
70
|
-
# MCP 工具测试(默认复用 OpenClaw memorySearch embedding 配置)
|
|
71
|
-
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"layer2_ensure","arguments":{}}}' \
|
|
72
|
-
| node index.js
|
|
73
|
-
|
|
74
|
-
# PostgreSQL 连接验证
|
|
75
|
-
PGPASSWORD='memos_local_20260312' psql -h 127.0.0.1 -U memos -d memos -Atqc "SELECT count(*) FROM memo;"
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
## 预期输出样例
|
|
79
|
-
|
|
80
|
-
### ✅ layer2_ensure 成功
|
|
81
|
-
```json
|
|
82
|
-
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"layer2_ensure:\n{\"ok\":true,\"steps\":[...]}\n"}]}}
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
### ❌ layer2_doctor 失败(OpenClaw embedding 配置缺失)
|
|
86
|
-
```
|
|
87
|
-
=== Layer2 Doctor v0.1.0 ===
|
|
88
|
-
|
|
89
|
-
Checks:
|
|
90
|
-
✅ PostgreSQL: connected, memo rows: 11398
|
|
91
|
-
❌ embedding: OpenClaw memorySearch embedding config not found
|
|
92
|
-
→ Fix: 配置 agents.defaults.memorySearch.remote.baseUrl/apiKey/model
|
|
93
|
-
✅ ingest_script: /var/lib/openclaw/.openclaw/workspace/scripts/ingest_session_raw_to_memos.py
|
|
94
|
-
✅ runtime_dir: /var/lib/openclaw/.openclaw/workspace/.layer2-runtime
|
|
95
|
-
✅ pg_module: pg package available
|
|
96
|
-
|
|
97
|
-
⚠️ Some checks failed — see above
|
|
31
|
+
# Start with PM2
|
|
32
|
+
npm run pm2:start
|
|
98
33
|
```
|
|
99
34
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
- 不做自动更新(v0.1)
|
|
103
|
-
- 每次调用前不做 ensureLatest 检查
|
|
104
|
-
- 手动升级:npm update 或重新安装包
|
|
35
|
+
---
|
|
105
36
|
|
|
106
|
-
|
|
37
|
+
<a name="中文"></a>
|
|
107
38
|
|
|
108
|
-
|
|
109
|
-
- 单次 semantic_search 最多 200 条 memo 初筛
|
|
110
|
-
- 若 OpenClaw memorySearch embedding 配置缺失,semantic_search 返回错误,其他功能正常
|
|
111
|
-
- trigger_ingest 为同步调用,超时时间 60s
|
|
39
|
+
## 中文
|
|
112
40
|
|
|
113
|
-
|
|
41
|
+
### 简介
|
|
42
|
+
**OpenClaw Memory Layer2** 是为 OpenClaw 生态设计的生产级长效记忆 MCP 服务。它通过结合 **Hindsight**(语义召回)与 **PostgreSQL Memos**(硬核实锤校验),解决了原始对话记录在召回时的“语义漂移”与“幻觉”问题。
|
|
114
43
|
|
|
115
|
-
|
|
116
|
-
|
|
44
|
+
### 核心特性
|
|
45
|
+
- **Hindsight 召回,Memos 裁决**:利用 Hindsight 进行广度语义联想,同时通过 Memos 进行严格的实体对齐(关键词校验),防止“张冠李戴”。
|
|
46
|
+
- **混合检索框架**:支持关键词 + 向量(余弦相似度)双路融合检索,针对中文场景进行了分词优化,大幅提升召回率。
|
|
47
|
+
- **生产级稳定性**:内置 PM2 进程管理,外部服务调用带有硬超时保护,避免阻塞主对话链。
|
|
48
|
+
- **净化入库 (Ingest)**:智能过滤工具日志、系统事件及元数据噪音,确保记忆库的纯净度。
|
|
117
49
|
|
|
50
|
+
### 快速开始
|
|
51
|
+
```bash
|
|
52
|
+
# 安装
|
|
53
|
+
npm install @bsbofmusic/openclaw-memory-layer2
|
|
118
54
|
|
|
119
|
-
|
|
55
|
+
# 运行诊断
|
|
56
|
+
npm run doctor
|
|
120
57
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
- 已接入工具:`hindsight_health`、`layer2_answer`
|
|
125
|
-
- 当前定位:`memos` 提供原始事实/原话证据,`Hindsight` 提供 recall / reflect / 聚合归纳
|
|
126
|
-
- 当前已通过的是**架构级联合验证**;若继续推进,下一阶段才是回答质量调优与常驻化收口。
|
|
58
|
+
# 使用 PM2 启动
|
|
59
|
+
npm run pm2:start
|
|
60
|
+
```
|
|
127
61
|
|
|
128
|
-
|
|
62
|
+
### 架构分工
|
|
63
|
+
1. **Layer 1 (OpenClaw Native)**: 负责稳定事实、规则、拍板结论。
|
|
64
|
+
2. **Layer 2 (This Package)**: 负责原话、细节、时间点、承诺。
|
|
129
65
|
|
|
130
|
-
|
|
66
|
+
---
|
|
131
67
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
npm run pm2:start
|
|
135
|
-
pm2 logs openclaw-memory-layer2
|
|
136
|
-
```
|
|
68
|
+
## License
|
|
69
|
+
MIT © [bsbofmusic](https://github.com/bsbofmusic)
|
package/hindsight.js
CHANGED
|
@@ -28,38 +28,73 @@ function loadHindsightConfig() {
|
|
|
28
28
|
|
|
29
29
|
async function hcFetch(path, options = {}) {
|
|
30
30
|
const cfg = loadHindsightConfig();
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
const base = cfg.baseUrl.replace(/\/$/, '');
|
|
32
|
+
const urlStr = `${base}${path}`;
|
|
33
|
+
let parsed;
|
|
34
|
+
try { parsed = new URL(urlStr); } catch { parsed = { hostname: '127.0.0.1', port: '8888', pathname: path, search: '' }; }
|
|
35
|
+
const lib = (parsed.protocol === 'https:') ? require('https') : require('http');
|
|
36
|
+
const timeoutMs = Math.min(Number(process.env.HINDSIGHT_TIMEOUT_MS || 3000), 3000);
|
|
37
|
+
const method = (options || {}).method || 'GET';
|
|
38
|
+
const headers = (options || {}).headers || {};
|
|
39
|
+
const body = (options || {}).body || null;
|
|
40
|
+
|
|
41
|
+
return new Promise(resolve => {
|
|
42
|
+
let settled = false;
|
|
43
|
+
const done = (val) => { if (!settled) { settled = true; resolve(val); } };
|
|
44
|
+
const req = lib.request({
|
|
45
|
+
hostname: parsed.hostname || '127.0.0.1',
|
|
46
|
+
port: parsed.port || '8888',
|
|
47
|
+
path: (parsed.pathname || '') + (parsed.search || ''),
|
|
48
|
+
method,
|
|
49
|
+
headers,
|
|
50
|
+
timeout: timeoutMs,
|
|
51
|
+
}, res => {
|
|
52
|
+
let data = '';
|
|
53
|
+
res.on('data', c => { data += c; });
|
|
54
|
+
res.on('end', () => {
|
|
55
|
+
let json = null;
|
|
56
|
+
try { json = JSON.parse(data); } catch { /* noop */ }
|
|
57
|
+
done({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, text: data, json });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
req.on('timeout', () => { req.destroy(); done({ ok: false, status: 0, detail: 'hindsight http timeout' }); });
|
|
61
|
+
req.on('error', e => done({ ok: false, status: 0, detail: e.message }));
|
|
62
|
+
if (body) req.write(body);
|
|
63
|
+
req.end();
|
|
64
|
+
setTimeout(() => { if (!settled) { req.destroy(); done({ ok: false, status: 0, detail: 'hindsight max-wait exceeded' }); } }, timeoutMs + 200);
|
|
65
|
+
});
|
|
37
66
|
}
|
|
38
67
|
|
|
39
68
|
async function healthcheck() {
|
|
40
69
|
const cfg = loadHindsightConfig();
|
|
70
|
+
console.error('[hindsight] healthcheck:start', JSON.stringify({ baseUrl: cfg.baseUrl, bankId: cfg.bankId }));
|
|
41
71
|
if (!cfg.enabled) return { ok: false, detail: 'HINDSIGHT_ENABLED=0' };
|
|
42
72
|
try {
|
|
43
73
|
const r = await hcFetch('/health');
|
|
74
|
+
console.error('[hindsight] healthcheck:/health', JSON.stringify({ ok: r.ok, status: r.status }));
|
|
44
75
|
if (r.ok) return { ok: true, detail: 'health endpoint reachable' };
|
|
45
76
|
} catch {}
|
|
46
77
|
try {
|
|
47
78
|
const r = await hcFetch('/');
|
|
79
|
+
console.error('[hindsight] healthcheck:/', JSON.stringify({ ok: r.ok, status: r.status }));
|
|
48
80
|
if (r.ok || r.status < 500) return { ok: true, detail: `root reachable (${r.status})` };
|
|
49
81
|
return { ok: false, detail: `HTTP ${r.status}` };
|
|
50
82
|
} catch (e) {
|
|
51
|
-
|
|
83
|
+
console.error('[hindsight] healthcheck:error', e?.name || 'Error', e?.message || String(e));
|
|
84
|
+
return { ok: false, detail: e?.message || String(e) };
|
|
52
85
|
}
|
|
53
86
|
}
|
|
54
87
|
|
|
55
88
|
async function ensureBank() {
|
|
56
89
|
const cfg = loadHindsightConfig();
|
|
90
|
+
console.error('[hindsight] ensureBank:start', JSON.stringify({ bankId: cfg.bankId }));
|
|
57
91
|
const body = JSON.stringify({ reflect_mission: 'Layer2 advanced memory bank for OpenClaw recall and reflection' });
|
|
58
92
|
return hcFetch(`/v1/default/banks/${encodeURIComponent(cfg.bankId)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body });
|
|
59
93
|
}
|
|
60
94
|
|
|
61
95
|
async function recall(query, { topK = 5 } = {}) {
|
|
62
96
|
const cfg = loadHindsightConfig();
|
|
97
|
+
console.error('[hindsight] recall:start', JSON.stringify({ bankId: cfg.bankId, topK, query: String(query).slice(0,80) }));
|
|
63
98
|
await ensureBank();
|
|
64
99
|
const body = JSON.stringify({ query, max_tokens: 4096, budget: 'mid' });
|
|
65
100
|
const path = `/v1/default/banks/${encodeURIComponent(cfg.bankId)}/memories/recall`;
|
|
@@ -74,6 +109,7 @@ async function recall(query, { topK = 5 } = {}) {
|
|
|
74
109
|
|
|
75
110
|
async function reflect(query) {
|
|
76
111
|
const cfg = loadHindsightConfig();
|
|
112
|
+
console.error('[hindsight] reflect:start', JSON.stringify({ bankId: cfg.bankId, query: String(query).slice(0,80) }));
|
|
77
113
|
await ensureBank();
|
|
78
114
|
const body = JSON.stringify({ query, include: { facts: {} }, max_tokens: 1024, budget: 'low' });
|
|
79
115
|
const path = `/v1/default/banks/${encodeURIComponent(cfg.bankId)}/reflect`;
|
package/index.js
CHANGED
|
@@ -84,10 +84,20 @@ function log(level, ...args) {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function extractDisplayText(raw) {
|
|
88
|
+
const s = String(raw || '').trim();
|
|
89
|
+
if (!s) return '';
|
|
90
|
+
const parts = s.split(/\n\s*\n/);
|
|
91
|
+
let body = parts.length > 1 ? parts.slice(1).join(' ').trim() : s;
|
|
92
|
+
body = body.replace(/\[(uid|source|chat_id|session|timestamp|sender|message_id|mode|part):[^\]]*\]/g, ' ');
|
|
93
|
+
body = body.replace(/\s+/g, ' ').trim();
|
|
94
|
+
return body;
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
// ─── PostgreSQL Pool ──────────────────────────────────────────────────────────
|
|
88
98
|
let pool = null;
|
|
89
99
|
function getPool() {
|
|
90
|
-
if (!pool) {
|
|
100
|
+
if (!pool || pool.ended) {
|
|
91
101
|
pool = new Pool(PG);
|
|
92
102
|
pool.on('error', err => log('error', 'PG pool error', err.message));
|
|
93
103
|
}
|
|
@@ -156,35 +166,94 @@ async function pgQuery(sql, params = [], timeoutMs = 8000) {
|
|
|
156
166
|
}
|
|
157
167
|
|
|
158
168
|
// ─── Semantic search over memos ──────────────────────────────────────────────
|
|
159
|
-
async function semanticSearch(query, { topK = 10, minScore = 0.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
const
|
|
169
|
+
async function semanticSearch(query, { topK = 10, minScore = 0.2 } = {}) {
|
|
170
|
+
// 混合检索模式:关键词召回 + 向量召回,双路融合提升准确率
|
|
171
|
+
const q = String(query || '').trim();
|
|
172
|
+
if (!q) return { ok: true, results: [] };
|
|
173
|
+
|
|
174
|
+
// 1. 关键词召回分支
|
|
175
|
+
const chars = Array.from(new Set(q.toLowerCase().split('').filter(c => c.trim().length > 0 && /[\u4e00-\u9fa5a-z0-9]/i.test(c)))).slice(0, 16);
|
|
176
|
+
const terms = Array.from(new Set(q.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean))).slice(0, 8);
|
|
177
|
+
const allTerms = Array.from(new Set([...chars, ...terms]));
|
|
178
|
+
|
|
179
|
+
const likeParams = [];
|
|
180
|
+
const clauses = [];
|
|
181
|
+
for (const t of terms) {
|
|
182
|
+
likeParams.push(`%${t}%`);
|
|
183
|
+
clauses.push(`LOWER(content) LIKE $${likeParams.length}`);
|
|
184
|
+
}
|
|
185
|
+
for (const c of chars) {
|
|
186
|
+
likeParams.push(`%${c}%`);
|
|
187
|
+
clauses.push(`LOWER(content) LIKE $${likeParams.length}`);
|
|
188
|
+
}
|
|
189
|
+
const whereLike = clauses.length ? `AND (${clauses.join(' OR ')})` : '';
|
|
190
|
+
const keywordRes = await pgQuery(
|
|
166
191
|
`SELECT id, creator_id, content, payload, created_ts, updated_ts
|
|
167
192
|
FROM memo
|
|
168
193
|
WHERE visibility = 'PRIVATE' AND LENGTH(content) > 20
|
|
194
|
+
${whereLike}
|
|
169
195
|
ORDER BY updated_ts DESC
|
|
170
|
-
LIMIT
|
|
196
|
+
LIMIT 30`,
|
|
197
|
+
likeParams
|
|
171
198
|
);
|
|
172
|
-
if (!res.ok) return { ok: false, error: res.error };
|
|
173
199
|
|
|
174
|
-
//
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
200
|
+
// 2. 向量召回分支(如果有embedding字段存在)
|
|
201
|
+
const embedRes = { rows: [] };
|
|
202
|
+
try {
|
|
203
|
+
const queryEmbedding = await getEmbedding(q);
|
|
204
|
+
const vecRes = await pgQuery(
|
|
205
|
+
`SELECT id, creator_id, content, payload, created_ts, updated_ts, 1 - (embedding <=> $1::vector) as score
|
|
206
|
+
FROM memo
|
|
207
|
+
WHERE visibility = 'PRIVATE' AND LENGTH(content) > 20
|
|
208
|
+
AND embedding IS NOT NULL
|
|
209
|
+
ORDER BY embedding <=> $1::vector
|
|
210
|
+
LIMIT 30`,
|
|
211
|
+
[JSON.stringify(queryEmbedding)]
|
|
212
|
+
);
|
|
213
|
+
if (vecRes.ok) embedRes.rows = vecRes.rows;
|
|
214
|
+
} catch {}
|
|
215
|
+
|
|
216
|
+
// 3. 结果去重合并
|
|
217
|
+
const merged = new Map();
|
|
218
|
+
// 关键词结果加权
|
|
219
|
+
keywordRes.rows?.forEach(row => {
|
|
220
|
+
if (!merged.has(row.id)) {
|
|
221
|
+
let score = 0;
|
|
222
|
+
const content = String(row.content || '').toLowerCase();
|
|
223
|
+
let hits = 0;
|
|
224
|
+
for (const t of terms) if (content.includes(t)) hits += 2;
|
|
225
|
+
for (const c of chars) if (content.includes(c)) hits += 0.5;
|
|
226
|
+
const totalPossible = terms.length * 2 + chars.length * 0.5;
|
|
227
|
+
const ratio = totalPossible > 0 ? hits / totalPossible : 0;
|
|
228
|
+
const recencyBoost = row.updated_ts ? 0.1 : 0;
|
|
229
|
+
score = parseFloat((ratio + recencyBoost).toFixed(4));
|
|
230
|
+
merged.set(row.id, { ...row, score, source: 'keyword' });
|
|
185
231
|
}
|
|
186
|
-
}
|
|
187
|
-
|
|
232
|
+
});
|
|
233
|
+
// 向量结果加权
|
|
234
|
+
embedRes.rows?.forEach(row => {
|
|
235
|
+
if (!merged.has(row.id)) {
|
|
236
|
+
merged.set(row.id, { ...row, score: parseFloat((row.score || 0).toFixed(4)), source: 'vector' });
|
|
237
|
+
} else {
|
|
238
|
+
// 双命中加权
|
|
239
|
+
const existing = merged.get(row.id);
|
|
240
|
+
existing.score = parseFloat((Math.max(existing.score, row.score) * 1.2).toFixed(4));
|
|
241
|
+
existing.source = 'hybrid';
|
|
242
|
+
merged.set(row.id, existing);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// 4. 排序取topK
|
|
247
|
+
const noisyContent = (content) => {
|
|
248
|
+
const s = String(content || '');
|
|
249
|
+
return /\{\"jsonrpc\":\"2\.0\"|layer2_answer:start|STDOUT\+STDERR|Internal task completion event|source: subagent|Stats: runtime|Action:/i.test(s);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const scored = Array.from(merged.values())
|
|
253
|
+
.filter(row => row.score >= Math.min(minScore, 0.1))
|
|
254
|
+
.filter(row => !noisyContent(row.content))
|
|
255
|
+
.sort((a, b) => b.score - a.score || (b.updated_ts || 0) - (a.updated_ts || 0));
|
|
256
|
+
|
|
188
257
|
return { ok: true, results: scored.slice(0, topK) };
|
|
189
258
|
}
|
|
190
259
|
|
|
@@ -484,42 +553,103 @@ const TOOLS = {
|
|
|
484
553
|
}
|
|
485
554
|
|
|
486
555
|
case 'layer2_answer': {
|
|
556
|
+
log('info', 'layer2_answer:start', JSON.stringify(args || {}));
|
|
487
557
|
const { query, topK = 5 } = args || {};
|
|
488
558
|
if (!query) return '❌ query is required';
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
559
|
+
|
|
560
|
+
// Stage 1: semantic search over memos (candidates)
|
|
561
|
+
log('info', 'layer2_answer:semantic_search');
|
|
562
|
+
const sem = await semanticSearch(query, { topK: 15, minScore: 0.35 });
|
|
563
|
+
const rawMemos = sem.ok ? sem.results : [];
|
|
564
|
+
const qTerms = String(query || '').toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
565
|
+
|
|
566
|
+
// Stage 2: Hindsight recall (candidates)
|
|
567
|
+
log('info', 'layer2_answer:hindsight_recall');
|
|
568
|
+
let recallMemories = [];
|
|
569
|
+
let hindsightUsed = false;
|
|
570
|
+
try {
|
|
571
|
+
const h = await new Promise(resolve => {
|
|
572
|
+
const _lib = require('http');
|
|
573
|
+
const _req = _lib.request({
|
|
574
|
+
hostname: '127.0.0.1', port: 8888,
|
|
575
|
+
path: '/health', method: 'GET'
|
|
576
|
+
}, _res => {
|
|
577
|
+
let _d = ''; _res.on('data', c => _d += c);
|
|
578
|
+
_res.on('end', () => resolve({ ok: _res.statusCode >= 200 && _res.statusCode < 300 }));
|
|
579
|
+
});
|
|
580
|
+
_req.on('timeout', () => { _req.destroy(); resolve({ ok: false }); });
|
|
581
|
+
_req.on('error', () => resolve({ ok: false }));
|
|
582
|
+
_req.setTimeout(2000);
|
|
583
|
+
_req.end();
|
|
584
|
+
setTimeout(() => resolve({ ok: false }), 2500);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
if (h?.ok) {
|
|
588
|
+
hindsightUsed = true;
|
|
589
|
+
const recall = await Promise.race([
|
|
590
|
+
hindsight.recall(query, { topK: 6 }),
|
|
591
|
+
new Promise(resolve => setTimeout(() => resolve({ ok: false }), 8000))
|
|
592
|
+
]);
|
|
593
|
+
recallMemories = Array.isArray(recall?.data?.results) ? recall.data.results : [];
|
|
594
|
+
}
|
|
595
|
+
} catch (_) {}
|
|
596
|
+
|
|
597
|
+
// Stage 3: The Judge (memos-as-judge)
|
|
598
|
+
// Combine and filter based on strict term matching if score is low
|
|
499
599
|
const facts = [];
|
|
500
|
-
|
|
501
|
-
|
|
600
|
+
const seenTexts = new Set();
|
|
601
|
+
|
|
602
|
+
// 1. Prioritize Hindsight results but verify them against query terms
|
|
603
|
+
const filteredRecall = recallMemories.filter(item => {
|
|
604
|
+
const t = String(item.text || '').toLowerCase();
|
|
605
|
+
if (!t.trim() || /pg 版 memos API 写入测试|发送了一条消息|没有完成|^这是一条 /.test(t)) return false;
|
|
606
|
+
// If we have query terms, check if the recall matches at least one (looser judge for hindsight)
|
|
607
|
+
return qTerms.length === 0 || qTerms.some(term => t.includes(term));
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
for (const item of filteredRecall.slice(0, 3)) {
|
|
611
|
+
const text = extractDisplayText(item.text || '').slice(0, 250);
|
|
612
|
+
if (text && !seenTexts.has(text)) {
|
|
613
|
+
facts.push(`- [recall] ${text}`);
|
|
614
|
+
seenTexts.add(text);
|
|
615
|
+
}
|
|
502
616
|
}
|
|
503
|
-
|
|
504
|
-
|
|
617
|
+
|
|
618
|
+
// 2. Add high-quality semantic hits as supporting evidence
|
|
619
|
+
const verifiedMemos = rawMemos.filter(item => {
|
|
620
|
+
const content = extractDisplayText(item.content || '').toLowerCase();
|
|
621
|
+
if (!content || seenTexts.has(content)) return false;
|
|
622
|
+
// High confidence threshold
|
|
623
|
+
if (item.score >= 0.85) return true;
|
|
624
|
+
// Medium confidence + strict term match
|
|
625
|
+
return item.score >= 0.45 && qTerms.every(term => content.includes(term));
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
for (const item of verifiedMemos.slice(0, Math.max(0, 5 - facts.length))) {
|
|
629
|
+
const content = extractDisplayText(item.content || '').slice(0, 250);
|
|
630
|
+
facts.push(`- [evidence] ${content}`);
|
|
631
|
+
seenTexts.add(content);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Stage 4: Synthesis
|
|
635
|
+
log('info', 'layer2_answer:hindsight_reflect');
|
|
636
|
+
let reflect = null;
|
|
637
|
+
if (hindsightUsed && facts.length > 0) {
|
|
638
|
+
reflect = await Promise.race([
|
|
639
|
+
hindsight.reflect(query),
|
|
640
|
+
new Promise(resolve => setTimeout(() => resolve(null), 12000))
|
|
641
|
+
]).catch(() => null);
|
|
505
642
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
const reasonLocked = /gotti|leah/i.test(combined) && /会摇|很会摇|摇起来|摇得/.test(combined);
|
|
510
|
-
let judgment = '未形成稳定归纳';
|
|
511
|
-
if (reasonLocked) {
|
|
512
|
-
judgment = '从已记录证据看,你喜欢 Gotti 的核心原因就是:她会摇。这是当前证据里最明确、最稳定的偏好线索。';
|
|
513
|
-
} else if (reflect?.ok) {
|
|
643
|
+
|
|
644
|
+
let judgment = facts.length > 0 ? '已找到相关证据,先按证据回答。' : '未查到足够证据';
|
|
645
|
+
if (reflect?.ok) {
|
|
514
646
|
judgment = typeof reflect.data === 'string'
|
|
515
|
-
? reflect.data.slice(0,
|
|
516
|
-
: String(reflect.data?.text || JSON.stringify(reflect.data)).slice(0,
|
|
517
|
-
} else if (evidence.length || recallMemories.length) {
|
|
518
|
-
judgment = '已找到相关证据,但当前 Hindsight 未稳定收口;先按证据做保守归纳。';
|
|
519
|
-
} else {
|
|
520
|
-
judgment = '未查到足够证据';
|
|
647
|
+
? reflect.data.slice(0, 800)
|
|
648
|
+
: String(reflect.data?.text || JSON.stringify(reflect.data)).slice(0, 800);
|
|
521
649
|
}
|
|
522
|
-
|
|
650
|
+
|
|
651
|
+
log('info', 'layer2_answer:return', `facts=${facts.length}`);
|
|
652
|
+
return `已确认事实:\n${facts.length ? facts.join('\n') : '- 无'}\n\n归纳判断:\n- ${judgment}\n\n不确定点:\n- ${hindsightUsed ? 'Hindsight 已作为增强层参与' : 'Hindsight 离线,仅使用本地证据'}\n\n[PRO-TIP] 证据召回由 memos + Hindsight 双路裁决:memos 负责硬核实锤(实体对齐),Hindsight 负责语义联想。`;
|
|
523
653
|
}
|
|
524
654
|
|
|
525
655
|
case 'layer2_version': {
|
|
@@ -587,15 +717,17 @@ async function handleLine(line) {
|
|
|
587
717
|
async function main() {
|
|
588
718
|
process.stdin.setEncoding('utf8');
|
|
589
719
|
let buffer = '';
|
|
590
|
-
process.stdin.on('data', chunk => {
|
|
720
|
+
process.stdin.on('data', async chunk => {
|
|
591
721
|
buffer += chunk;
|
|
592
722
|
let idx;
|
|
593
723
|
while ((idx = buffer.indexOf('\n')) >= 0) {
|
|
594
724
|
const line = buffer.slice(0, idx);
|
|
595
725
|
buffer = buffer.slice(idx + 1);
|
|
596
|
-
|
|
726
|
+
try {
|
|
727
|
+
await handleLine(line);
|
|
728
|
+
} catch (e) {
|
|
597
729
|
process.stderr.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { message: e.message } }) + '\n');
|
|
598
|
-
}
|
|
730
|
+
}
|
|
599
731
|
}
|
|
600
732
|
});
|
|
601
733
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bsbofmusic/openclaw-memory-layer2",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Layer2 Memory MCP Server — Hindsight + memos unified validation over memos PostgreSQL, reusing OpenClaw memorySearch embedding config",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Layer2 Memory MCP Server — Hindsight + memos unified validation over memos PostgreSQL, reusing OpenClaw memorySearch embedding config",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"openclaw-memory-layer2": "./index.js"
|