@bsbofmusic/openclaw-memory-layer2 0.2.0 → 0.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.
Files changed (3) hide show
  1. package/hindsight.js +43 -7
  2. package/index.js +187 -55
  3. package/package.json +2 -2
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 url = `${cfg.baseUrl.replace(/\/$/, '')}${path}`;
32
- const res = await fetch(url, options);
33
- const text = await res.text();
34
- let json = null;
35
- try { json = JSON.parse(text); } catch {}
36
- return { ok: res.ok, status: res.status, text, json };
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
- return { ok: false, detail: e.message };
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.5 } = {}) {
160
- const embed = await getEmbedding(query);
161
- // memos stores content in `content` column; we search raw text similarity
162
- // For v0.1 we do a lightweight approximate: pull recent memos and rank by
163
- // keyword overlap + trust that the shared OpenClaw embedding model handles semantics.
164
- // A full vector index (pgvector) can be added in v0.2.
165
- const res = await pgQuery(
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 40`
196
+ LIMIT 30`,
197
+ likeParams
171
198
  );
172
- if (!res.ok) return { ok: false, error: res.error };
173
199
 
174
- // Score each memo by cosine similarity of query embedding vs memo text embedding
175
- const scored = [];
176
- for (const row of res.rows) {
177
- try {
178
- const rowEmbed = await getEmbedding(row.content.slice(0, 2000));
179
- const score = cosineSim(embed, rowEmbed);
180
- if (score >= minScore) {
181
- scored.push({ ...row, score: parseFloat(score.toFixed(4)) });
182
- }
183
- } catch {
184
- // skip on embed failure
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
- scored.sort((a, b) => b.score - a.score);
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
- const sem = await semanticSearch(query, { topK, minScore: 0.45 });
490
- const evidence = sem.ok ? sem.results.slice(0, topK) : [];
491
- const recall = await hindsight.recall(query, { topK: 6 });
492
- const h = await hindsight.healthcheck();
493
- let reflect = null;
494
- const fastPath = /为什么喜欢|偏好|原因/.test(query) && /gotti|leah/i.test(query);
495
- if (h.ok && !fastPath) {
496
- reflect = await hindsight.reflect(query);
497
- }
498
- const recallMemories = Array.isArray(recall?.data?.results) ? recall.data.results : [];
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
- for (const item of evidence.slice(0, 3)) {
501
- facts.push(`- [score=${item.score}] ${String(item.content).slice(0, 180)}`);
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
- for (const item of recallMemories.slice(0, 3)) {
504
- facts.push(`- [recall] ${String(item.text || '').slice(0, 180)}`);
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
- const joined = evidence.map(x => String(x.content || '')).join('\n');
507
- const recallJoined = recallMemories.map(x => String(x.text || '')).join('\n');
508
- const combined = `${joined}\n${recallJoined}\n${query}`;
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, 600)
516
- : String(reflect.data?.text || JSON.stringify(reflect.data)).slice(0, 600);
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
- return `已确认事实:\n${facts.length ? facts.join('\n') : '- 无'}\n\n归纳判断:\n- ${judgment}\n\n不确定点:\n- ${reasonLocked ? '当前答案已被证据优先规则锁定;若底层记忆变更需重新验证' : (reflect?.ok ? 'Hindsight 已参与归纳,但仍应以证据为准' : 'Hindsight 未接通,当前仅基于 memos semantic evidence')}`;
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
- handleLine(line).catch(e => {
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.0",
4
- "description": "Layer2 Memory MCP Server — Hindsight + memos unified validation over memos PostgreSQL, reusing OpenClaw memorySearch embedding config",
3
+ "version": "0.2.1",
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"