memory-lancedb-pro 1.0.8 → 1.0.10

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/CHANGELOG.md CHANGED
@@ -1,10 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.10
4
+
5
+ - Fix: avoid blocking OpenClaw gateway startup on external network calls by running startup self-checks in the background with timeouts.
6
+
7
+ ## 1.0.9
8
+
9
+ - Change: update default `retrieval.rerankModel` to `jina-reranker-v3` (still fully configurable).
10
+
3
11
  ## 1.0.8
4
12
 
5
13
  - Add: JSONL distill extractor supports optional agent allowlist via env var `OPENCLAW_JSONL_DISTILL_ALLOWED_AGENT_IDS` (default off / compatible).
6
14
 
7
-
8
15
  ## 1.0.7
9
16
 
10
17
  - Fix: resolve `agentId` from hook context (`ctx?.agentId`) for `before_agent_start` and `agent_end`, restoring per-agent scope isolation when using multi-agent setups.
package/README.md CHANGED
@@ -314,7 +314,7 @@ openclaw config get plugins.slots.memory
314
314
  "minScore": 0.3,
315
315
  "rerank": "cross-encoder",
316
316
  "rerankApiKey": "${JINA_API_KEY}",
317
- "rerankModel": "jina-reranker-v2-base-multilingual",
317
+ "rerankModel": "jina-reranker-v3",
318
318
  "rerankEndpoint": "https://api.jina.ai/v1/rerank",
319
319
  "rerankProvider": "jina",
320
320
  "candidatePoolSize": 20,
@@ -362,7 +362,7 @@ Cross-encoder reranking supports multiple providers via `rerankProvider`:
362
362
 
363
363
  | Provider | `rerankProvider` | Endpoint | Example Model |
364
364
  |----------|-----------------|----------|---------------|
365
- | **Jina** (default) | `jina` | `https://api.jina.ai/v1/rerank` | `jina-reranker-v2-base-multilingual` |
365
+ | **Jina** (default) | `jina` | `https://api.jina.ai/v1/rerank` | `jina-reranker-v3` |
366
366
  | **SiliconFlow** (free tier available) | `siliconflow` | `https://api.siliconflow.com/v1/rerank` | `BAAI/bge-reranker-v2-m3`, `Qwen/Qwen3-Reranker-8B` |
367
367
  | **Pinecone** | `pinecone` | `https://api.pinecone.io/rerank` | `bge-reranker-v2-m3` |
368
368
 
@@ -410,7 +410,21 @@ OpenClaw already persists **full session transcripts** as JSONL files:
410
410
 
411
411
  This plugin focuses on **high-quality long-term memory**. If you dump raw transcripts into LanceDB, retrieval quality quickly degrades.
412
412
 
413
- Instead, you can run an **hourly distiller** that:
413
+ Instead, **recommended (2026-02+)** is a **non-blocking `/new` pipeline**:
414
+
415
+ - Trigger: `command:new` (you type `/new`)
416
+ - Hook: enqueue a tiny JSON task file (fast; no LLM calls inside the hook)
417
+ - Worker: a user-level systemd service watches the inbox and runs **Gemini Map-Reduce** on the session JSONL transcript
418
+ - Store: writes **0–20** high-signal, atomic lessons into LanceDB Pro via `openclaw memory-pro import`
419
+ - Keywords: each memory includes `Keywords (zh)` with a simple taxonomy (Entity + Action + Symptom). Entity keywords must be copied verbatim from the transcript (no hallucinated project names).
420
+ - Notify: optional Telegram/Discord notification (even if 0 lessons)
421
+
422
+ See the self-contained example files in:
423
+ - `examples/new-session-distill/`
424
+
425
+ ---
426
+
427
+ Legacy option: an **hourly distiller** cron that:
414
428
 
415
429
  1) Incrementally reads only the **newly appended tail** of each session JSONL (byte-offset cursor)
416
430
  2) Filters noise (tool output, injected `<relevant-memories>`, logs, boilerplate)
package/README_CN.md CHANGED
@@ -112,7 +112,7 @@ Query → BM25 FTS ─────┘
112
112
 
113
113
  ### 2. 跨编码器 Rerank
114
114
 
115
- - **Jina Reranker API**: `jina-reranker-v2-base-multilingual`(5s 超时保护)
115
+ - **Jina Reranker API**: `jina-reranker-v3`(5s 超时保护)
116
116
  - **混合评分**: 60% cross-encoder score + 40% 原始融合分
117
117
  - **降级策略**: API 失败时回退到 cosine similarity rerank
118
118
 
@@ -315,7 +315,7 @@ openclaw config get plugins.slots.memory
315
315
  "minScore": 0.3,
316
316
  "rerank": "cross-encoder",
317
317
  "rerankApiKey": "${JINA_API_KEY}",
318
- "rerankModel": "jina-reranker-v2-base-multilingual",
318
+ "rerankModel": "jina-reranker-v3",
319
319
  "candidatePoolSize": 20,
320
320
  "recencyHalfLifeDays": 14,
321
321
  "recencyWeight": 0.1,
@@ -365,7 +365,21 @@ OpenClaw 会把每个 Agent 的完整会话自动落盘为 JSONL:
365
365
 
366
366
  但 JSONL 含大量噪声(tool 输出、系统块、重复回调等),**不建议直接把原文塞进 LanceDB**。
367
367
 
368
- 本插件提供一个安全的 extractor 脚本 `scripts/jsonl_distill.py`,配合 OpenClaw `cron` + 独立 distiller agent,实现“增量蒸馏 → 高质量记忆入库”:
368
+ **推荐方案(2026-02+)**:使用 **/new 非阻塞沉淀管线**(Hooks + systemd worker),在你执行 `/new` 时异步提取高价值经验并写入 LanceDB Pro:
369
+
370
+ - 触发:`command:new`(你在聊天里发送 `/new`)
371
+ - Hook:只投递一个很小的 task.json(毫秒级,不调用 LLM,不阻塞 `/new`)
372
+ - Worker:systemd 常驻进程监听队列,读取 session `.jsonl`,用 Gemini **Map-Reduce** 抽取 0~20 条高信噪比记忆
373
+ - 写入:通过 `openclaw memory-pro import` 写入 LanceDB Pro(插件内部仍会 embedding + 查重)
374
+ - 中文关键词:每条记忆包含 `Keywords (zh)`,并遵循三要素(实体/动作/症状)。其中“实体关键词”必须从 transcript 原文逐字拷贝(禁止编造项目名)。
375
+ - 通知:可选(可做到即使 0 条也通知)
376
+
377
+ 示例文件:
378
+ - `examples/new-session-distill/`
379
+
380
+ ---
381
+
382
+ Legacy 方案:本插件也提供一个安全的 extractor 脚本 `scripts/jsonl_distill.py`,配合 OpenClaw 的 `cron` + 独立 distiller agent,实现“增量蒸馏 → 高质量记忆入库”:(适合不依赖 `/new` 的全自动场景)
369
383
 
370
384
  - 只读取每个 JSONL 文件**新增尾巴**(byte offset cursor),避免重复和 token 浪费
371
385
  - 生成一个小型 batch JSON
@@ -0,0 +1,19 @@
1
+ # New Session Distillation (Recommended)
2
+
3
+ This example shows a **non-blocking /new distillation pipeline**:
4
+
5
+ - Trigger: `command:new` (when you type `/new`)
6
+ - Hook: enqueue a small JSON task file (fast, no LLM calls)
7
+ - Worker: a user-level systemd service watches the inbox and runs **Gemini Map-Reduce** over the session JSONL transcript
8
+ - Storage: write high-signal, atomic lessons into LanceDB Pro via `openclaw memory-pro import`
9
+ - Notify: send a notification message (optional)
10
+
11
+ Files included:
12
+ - `hook/enqueue-lesson-extract/` — OpenClaw workspace hook
13
+ - `worker/lesson-extract-worker.mjs` — Map-Reduce extractor + importer + notifier
14
+ - `worker/systemd/lesson-extract-worker.service` — user systemd unit
15
+
16
+ You must provide:
17
+ - `GEMINI_API_KEY` in an env file loaded by systemd
18
+
19
+ Install steps are documented in the main repo README.
@@ -0,0 +1,21 @@
1
+ ---
2
+ name: enqueue-lesson-extract
3
+ description: "Enqueue a lesson-extraction task on /new (async Map-Reduce → LanceDB Pro)"
4
+ metadata:
5
+ {
6
+ "openclaw": {
7
+ "emoji": "🧾",
8
+ "events": ["command:new"]
9
+ }
10
+ }
11
+ ---
12
+
13
+ # Enqueue Lesson Extract Hook
14
+
15
+ Writes a small JSON task file to a queue directory when `/new` is issued.
16
+
17
+ This is intentionally fast and non-blocking. A separate systemd worker consumes tasks and:
18
+ - reads the session JSONL transcript
19
+ - runs Map-Reduce extraction with Gemini Flash
20
+ - writes high-signal, deduped lessons into LanceDB Pro
21
+ - sends a notification (optional)
@@ -0,0 +1,102 @@
1
+ import type { HookHandler } from "../../src/hooks/hooks.js";
2
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ function findSessionFile(event: any): string | null {
8
+ const prev = event.context?.previousSessionEntry;
9
+ const curr = event.context?.sessionEntry;
10
+
11
+ const candidates = [prev?.sessionFile, curr?.sessionFile].filter(Boolean);
12
+
13
+ for (const file of candidates) {
14
+ if (file && existsSync(file)) return file;
15
+ }
16
+
17
+ // If the file was renamed to .reset.*, look for the latest one
18
+ for (const file of candidates) {
19
+ if (!file) continue;
20
+ const dir = dirname(file);
21
+ const base = basename(file);
22
+ const resetPrefix = `${base}.reset.`;
23
+ try {
24
+ const resetFiles = readdirSync(dir)
25
+ .filter((name: string) => name.startsWith(resetPrefix))
26
+ .sort();
27
+ if (resetFiles.length > 0) return join(dir, resetFiles[resetFiles.length - 1]);
28
+ } catch {
29
+ // ignore
30
+ }
31
+ }
32
+
33
+ return null;
34
+ }
35
+
36
+ function sha1(input: string): string {
37
+ return createHash("sha1").update(input).digest("hex");
38
+ }
39
+
40
+ function repoRoot(): string {
41
+ // examples/new-session-distill/hook/enqueue-lesson-extract/handler.ts -> up to repo root
42
+ const here = dirname(fileURLToPath(import.meta.url));
43
+ return join(here, "..", "..", "..", "..", "..");
44
+ }
45
+
46
+ const handler: HookHandler = async (event) => {
47
+ if (event.type !== "command" || event.action !== "new") return;
48
+
49
+ try {
50
+ const sessionKey = event.sessionKey || "unknown";
51
+ const ctxEntry = (event.context?.previousSessionEntry || event.context?.sessionEntry) as any;
52
+ const sessionId = (ctxEntry?.sessionId as string) || "unknown";
53
+ const source = (event.context?.commandSource as string) || "unknown";
54
+
55
+ const sessionFile = findSessionFile(event);
56
+ if (!sessionFile) {
57
+ console.error("[enqueue-lesson-extract] No session file found; skipping");
58
+ return;
59
+ }
60
+
61
+ const root = repoRoot();
62
+ const queueDir = join(root, "workspaces", "main", "tasks", "lesson-extract", "inbox");
63
+ mkdirSync(queueDir, { recursive: true });
64
+
65
+ const ts = new Date(event.timestamp || Date.now()).toISOString();
66
+ const taskId = sha1([sessionKey, sessionId, sessionFile, ts].join("|"));
67
+
68
+ const task = {
69
+ taskId,
70
+ agentId: "main",
71
+ scope: "agent:main",
72
+ event: {
73
+ type: "command:new",
74
+ timestamp: ts,
75
+ sessionKey,
76
+ source,
77
+ },
78
+ session: {
79
+ sessionId,
80
+ sessionFile,
81
+ },
82
+ extract: {
83
+ maxFinal: 20,
84
+ mapChunkChars: 12000,
85
+ mapOverlapMsgs: 10,
86
+ },
87
+ };
88
+
89
+ const safeTs = ts.replace(/[:.]/g, "-");
90
+ const filename = `${safeTs}-${taskId.slice(0, 8)}.json`;
91
+ const outPath = join(queueDir, filename);
92
+
93
+ writeFileSync(outPath, JSON.stringify(task, null, 2) + "\n", "utf-8");
94
+ } catch (err) {
95
+ console.error(
96
+ "[enqueue-lesson-extract] Error:",
97
+ err instanceof Error ? err.message : String(err)
98
+ );
99
+ }
100
+ };
101
+
102
+ export default handler;
@@ -0,0 +1,412 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * lesson-extract-worker (example)
5
+ *
6
+ * Watches a repo-backed inbox for lesson extraction tasks and processes them asynchronously:
7
+ * - reads session JSONL transcript (streaming)
8
+ * - Map: per-chunk extraction via Gemini (native API)
9
+ * - Reduce: merge/dedupe/score -> 0..20 lessons
10
+ * - writes to LanceDB Pro via `openclaw memory-pro import`
11
+ * - sends Telegram notification via `openclaw message send` (optional)
12
+ */
13
+
14
+ import fs from "node:fs";
15
+ import fsp from "node:fs/promises";
16
+ import path from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { spawn } from "node:child_process";
19
+ import readline from "node:readline";
20
+
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = path.dirname(__filename);
23
+
24
+ // In your deployment, set LESSON_QUEUE_ROOT to your workspace queue.
25
+ // By default we assume repo layout similar to OpenClaw-Memory.
26
+ const REPO_ROOT = path.resolve(__dirname, "..", "..", "..", "..", "..");
27
+ const QUEUE_ROOT = process.env.LESSON_QUEUE_ROOT || path.join(REPO_ROOT, "workspaces", "main", "tasks", "lesson-extract");
28
+
29
+ const INBOX = path.join(QUEUE_ROOT, "inbox");
30
+ const PROCESSING = path.join(QUEUE_ROOT, "processing");
31
+ const DONE = path.join(QUEUE_ROOT, "done");
32
+ const ERROR = path.join(QUEUE_ROOT, "error");
33
+
34
+ const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
35
+ const GEMINI_MODEL = process.env.GEMINI_MODEL || "gemini-3-flash-preview";
36
+
37
+ const ONCE = process.argv.includes("--once");
38
+
39
+ function ensureDirs() {
40
+ for (const d of [INBOX, PROCESSING, DONE, ERROR]) {
41
+ fs.mkdirSync(d, { recursive: true });
42
+ }
43
+ }
44
+
45
+ function nowIso() {
46
+ return new Date().toISOString();
47
+ }
48
+
49
+ function run(cmd, args, opts = {}) {
50
+ return new Promise((resolve) => {
51
+ const child = spawn(cmd, args, { ...opts, stdio: ["ignore", "pipe", "pipe"] });
52
+ let out = "";
53
+ let err = "";
54
+ child.stdout.on("data", (d) => (out += d.toString("utf-8")));
55
+ child.stderr.on("data", (d) => (err += d.toString("utf-8")));
56
+ child.on("close", (code) => resolve({ code: code ?? 0, out, err }));
57
+ });
58
+ }
59
+
60
+ function safeJsonParse(text) {
61
+ try {
62
+ return JSON.parse(text);
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ function normalizeText(s) {
69
+ return (s || "")
70
+ .trim()
71
+ .replace(/\s+/g, " ")
72
+ .replace(/[“”]/g, '"')
73
+ .replace(/[‘’]/g, "'")
74
+ .toLowerCase();
75
+ }
76
+
77
+ function detectLang(text) {
78
+ const s = text || "";
79
+ const cjk = (s.match(/[\u4e00-\u9fff]/g) || []).length;
80
+ const latin = (s.match(/[A-Za-z]/g) || []).length;
81
+ if (cjk > latin * 0.8) return "zh";
82
+ if (latin > cjk * 0.8) return "en";
83
+ return "mixed";
84
+ }
85
+
86
+ async function* iterJsonlMessages(sessionFile) {
87
+ const stream = fs.createReadStream(sessionFile, { encoding: "utf-8" });
88
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
89
+
90
+ let id = 0;
91
+ for await (const line of rl) {
92
+ if (!line) continue;
93
+ const obj = safeJsonParse(line);
94
+ if (!obj || obj.type !== "message") continue;
95
+ const m = obj.message;
96
+ if (!m || (m.role !== "user" && m.role !== "assistant")) continue;
97
+
98
+ let text = "";
99
+ if (typeof m.content === "string") {
100
+ text = m.content;
101
+ } else if (Array.isArray(m.content)) {
102
+ text = m.content
103
+ .filter((c) => c && c.type === "text" && c.text)
104
+ .map((c) => c.text)
105
+ .join("\n");
106
+ }
107
+ text = (text || "").trim();
108
+ if (!text) continue;
109
+
110
+ id++;
111
+ yield {
112
+ id,
113
+ role: m.role,
114
+ timestamp: obj.timestamp || "",
115
+ text,
116
+ };
117
+ }
118
+ }
119
+
120
+ async function buildChunksFromJsonl(sessionFile, { maxChars = 12000, overlapMsgs = 10, maxChunks = 200 } = {}) {
121
+ const chunks = [];
122
+ let chunk = [];
123
+ let size = 0;
124
+
125
+ const sampleTexts = [];
126
+
127
+ for await (const m of iterJsonlMessages(sessionFile)) {
128
+ if (sampleTexts.length < 200) sampleTexts.push(m.text);
129
+
130
+ const line = `[${m.role === "user" ? "U" : "A"}${m.id}] ${m.text}\n`;
131
+
132
+ if (size + line.length > maxChars && chunk.length > 0) {
133
+ chunks.push(chunk);
134
+ if (chunks.length >= maxChunks) break;
135
+
136
+ chunk = chunk.slice(Math.max(0, chunk.length - overlapMsgs));
137
+ size = chunk.reduce((acc, mm) => acc + (`[${mm.role === "user" ? "U" : "A"}${mm.id}] ${mm.text}\n`).length, 0);
138
+ }
139
+
140
+ chunk.push(m);
141
+ size += line.length;
142
+ }
143
+
144
+ if (chunk.length > 0 && chunks.length < maxChunks) chunks.push(chunk);
145
+
146
+ const lang = detectLang(sampleTexts.join("\n"));
147
+ const messageCount = chunk.length === 0 && chunks.length === 0 ? 0 : chunks[chunks.length - 1][chunks[chunks.length - 1].length - 1].id;
148
+
149
+ return { chunks, lang, messageCount };
150
+ }
151
+
152
+ function buildMapPrompt({ lang, chunk }) {
153
+ const langInstr = lang === "zh" ? "请用中文输出 lessons。" : lang === "en" ? "Output lessons in English." : "Follow the dominant language of the transcript.";
154
+
155
+ return `You are extracting high-signal technical lessons from a chat transcript chunk.\n\nRules:\n- Output STRICT JSON only. No markdown, no backticks.\n- If nothing valuable, output: {\"lessons\":[]}\n- Max 8 lessons.\n- Each lesson.text must be <= 480 characters.\n- Categories: fact | decision | preference | other (use fact/decision primarily).\n- importance: number 0..1 (high-signal: 0.8-0.95).\n- evidence MUST quote exact short snippets from the chunk and include message_ids.\n- Do NOT include secrets/tokens/credentials.\n- Add Keywords (zh) inside each lesson:\n - Include >=1 Entity keyword that appears verbatim in the chunk (project/library/tool/service/config key/error code).\n - Include >=1 Action keyword (e.g., 修复/回滚/重启/迁移/去重/限流).\n - Include >=1 Symptom keyword (e.g., OOM/超时/429/重复/命中率差).\n - Do NOT invent entity names; copy entity keywords from the chunk.\n\n${langInstr}\n\nChunk:\n${chunk.map((m) => `[${m.role === "user" ? "U" : "A"}${m.id}] ${m.text}`).join("\n\n")}\n\nReturn JSON schema:\n{\n \"lessons\": [\n {\n \"category\": \"fact\",\n \"importance\": 0.8,\n \"text\": \"Pitfall: ... Cause: ... Fix: ... Prevention: ...\",\n \"evidence\": [\n {\"message_ids\":[12,13],\"quote\":\"...\"}\n ],\n \"tags\": [\"optional\"]\n }\n ]\n}`;
156
+ }
157
+
158
+ async function geminiGenerateJson(prompt) {
159
+ if (!GEMINI_API_KEY) throw new Error("GEMINI_API_KEY is not set");
160
+
161
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`;
162
+
163
+ const body = {
164
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
165
+ generationConfig: {
166
+ temperature: 0.2,
167
+ maxOutputTokens: 4096,
168
+ },
169
+ };
170
+
171
+ const res = await fetch(url, {
172
+ method: "POST",
173
+ headers: { "Content-Type": "application/json" },
174
+ body: JSON.stringify(body),
175
+ });
176
+
177
+ const json = await res.json();
178
+ if (!res.ok) {
179
+ throw new Error(`Gemini error ${res.status}: ${JSON.stringify(json).slice(0, 500)}`);
180
+ }
181
+
182
+ const text = json?.candidates?.[0]?.content?.parts?.map((p) => p.text).join("") || "";
183
+ return text;
184
+ }
185
+
186
+ function coerceLessons(obj) {
187
+ const lessons = Array.isArray(obj?.lessons) ? obj.lessons : [];
188
+ return lessons
189
+ .filter((l) => l && typeof l.text === "string" && l.text.trim().length >= 10)
190
+ .map((l) => ({
191
+ category: ["fact", "decision", "preference", "other"].includes(l.category) ? l.category : "other",
192
+ importance: typeof l.importance === "number" ? l.importance : 0.7,
193
+ text: l.text.trim().slice(0, 480),
194
+ evidence: Array.isArray(l.evidence) ? l.evidence : [],
195
+ tags: Array.isArray(l.tags) ? l.tags : [],
196
+ }));
197
+ }
198
+
199
+ function scoreLesson(l) {
200
+ let s = 0;
201
+ const t = l.text || "";
202
+ if (/pitfall\s*:|cause\s*:|fix\s*:|prevention\s*:/i.test(t)) s += 2;
203
+ if (/decision principle|trigger\s*:|action\s*:/i.test(t)) s += 2;
204
+ if (/\b(openclaw|docker|systemd|ssh|git|api|json|yaml|config)\b/i.test(t)) s += 1;
205
+ if (t.length < 120) s += 0.5;
206
+ if (l.evidence?.length >= 1) s += 1;
207
+ if (l.evidence?.length >= 2) s += 0.5;
208
+ const imp = Math.max(0, Math.min(1, l.importance ?? 0.7));
209
+ s += imp;
210
+ return s;
211
+ }
212
+
213
+ function reduceLessons(allLessons, maxFinal = 20) {
214
+ const seen = new Set();
215
+ const merged = [];
216
+
217
+ for (const l of allLessons) {
218
+ const key = normalizeText(l.text);
219
+ if (!key) continue;
220
+ if (seen.has(key)) continue;
221
+ seen.add(key);
222
+ merged.push(l);
223
+ }
224
+
225
+ merged.sort((a, b) => scoreLesson(b) - scoreLesson(a));
226
+
227
+ const filtered = merged.filter((l) => {
228
+ if (!l.evidence || l.evidence.length === 0) return false;
229
+ const t = normalizeText(l.text);
230
+ if (t.length < 20) return false;
231
+ if (/(be careful|best practice|should|建议|注意)/.test(t) && !/(cause|fix|prevention|trigger|action|原因|修复|预防|触发)/.test(t)) {
232
+ return false;
233
+ }
234
+ return true;
235
+ });
236
+
237
+ return filtered.slice(0, maxFinal);
238
+ }
239
+
240
+ async function importToLanceDb({ lessons, scope }) {
241
+ const importFile = path.join("/tmp", `lesson-import-${Date.now()}.json`);
242
+ const payload = {
243
+ memories: lessons.map((l) => ({
244
+ text: l.text,
245
+ importance: Math.max(0.0, Math.min(1.0, l.importance ?? 0.7)),
246
+ category: l.category,
247
+ })),
248
+ };
249
+ await fsp.writeFile(importFile, JSON.stringify(payload), "utf-8");
250
+
251
+ const { code, out, err } = await run("openclaw", ["memory-pro", "import", importFile, "--scope", scope], { cwd: REPO_ROOT });
252
+ await fsp.unlink(importFile).catch(() => {});
253
+
254
+ return { code, out, err };
255
+ }
256
+
257
+ async function notifyTelegram(chatId, message) {
258
+ const args = ["message", "send", "--channel", "telegram", "--target", String(chatId), "--message", message];
259
+ await run("openclaw", args, { cwd: REPO_ROOT });
260
+ }
261
+
262
+ async function processTaskFile(taskPath) {
263
+ const started = Date.now();
264
+ const taskRaw = await fsp.readFile(taskPath, "utf-8");
265
+ const task = JSON.parse(taskRaw);
266
+
267
+ const baseName = path.basename(taskPath);
268
+ const processingPath = path.join(PROCESSING, baseName);
269
+ await fsp.rename(taskPath, processingPath);
270
+
271
+ const result = {
272
+ taskId: task.taskId,
273
+ startedAt: nowIso(),
274
+ finishedAt: null,
275
+ ok: false,
276
+ sessionId: task.session?.sessionId,
277
+ sessionFile: task.session?.sessionFile,
278
+ stats: {},
279
+ error: null,
280
+ };
281
+
282
+ try {
283
+ const sessionFile = task.session?.sessionFile;
284
+ if (!sessionFile || !fs.existsSync(sessionFile)) {
285
+ throw new Error(`sessionFile missing or not found: ${sessionFile}`);
286
+ }
287
+
288
+ const { chunks, lang, messageCount } = await buildChunksFromJsonl(sessionFile, {
289
+ maxChars: task.extract?.mapChunkChars ?? 12000,
290
+ overlapMsgs: task.extract?.mapOverlapMsgs ?? 10,
291
+ maxChunks: 200,
292
+ });
293
+
294
+ const allLessons = [];
295
+ let mapErrors = 0;
296
+
297
+ for (let idx = 0; idx < chunks.length; idx++) {
298
+ const prompt = buildMapPrompt({ lang, chunk: chunks[idx] });
299
+ try {
300
+ const text = await geminiGenerateJson(prompt);
301
+ const obj = safeJsonParse(text);
302
+ if (!obj) {
303
+ mapErrors++;
304
+ continue;
305
+ }
306
+ const lessons = coerceLessons(obj);
307
+ for (const l of lessons) allLessons.push(l);
308
+ } catch {
309
+ mapErrors++;
310
+ }
311
+ }
312
+
313
+ const reduced = reduceLessons(allLessons, task.extract?.maxFinal ?? 20);
314
+
315
+ const scope = task.scope || "agent:main";
316
+ const importRes = await importToLanceDb({ lessons: reduced, scope });
317
+
318
+ const durationMs = Date.now() - started;
319
+ result.ok = importRes.code === 0;
320
+ result.finishedAt = nowIso();
321
+ result.stats = {
322
+ lang,
323
+ messages: messageCount,
324
+ chunks: chunks.length,
325
+ mapCandidates: allLessons.length,
326
+ mapErrors,
327
+ reduced: reduced.length,
328
+ importCode: importRes.code,
329
+ durationMs,
330
+ };
331
+
332
+ const notifyChatId = task.notify?.telegramChatId;
333
+ if (notifyChatId) {
334
+ const text = [
335
+ `Lesson Extract ✅ (${task.agentId || "main"})`,
336
+ `taskId: ${task.taskId?.slice(0, 8) || "unknown"}`,
337
+ `sessionId: ${task.session?.sessionId || "unknown"}`,
338
+ `lang: ${lang}`,
339
+ `messages: ${messageCount}, chunks: ${chunks.length}`,
340
+ `candidates: ${allLessons.length}, reduced: ${reduced.length}`,
341
+ `import: code=${importRes.code}`,
342
+ `time: ${(durationMs / 1000).toFixed(1)}s`,
343
+ ].join("\n");
344
+ await notifyTelegram(notifyChatId, text);
345
+ }
346
+
347
+ const donePath = path.join(DONE, `${task.taskId}.json`);
348
+ await fsp.writeFile(donePath, JSON.stringify(result, null, 2) + "\n", "utf-8");
349
+ await fsp.unlink(processingPath).catch(() => {});
350
+ } catch (err) {
351
+ result.ok = false;
352
+ result.finishedAt = nowIso();
353
+ result.error = err instanceof Error ? err.message : String(err);
354
+
355
+ const durationMs = Date.now() - started;
356
+ result.stats.durationMs = durationMs;
357
+
358
+ const notifyChatId = task.notify?.telegramChatId;
359
+ if (notifyChatId) {
360
+ await notifyTelegram(
361
+ notifyChatId,
362
+ `Lesson Extract ❌ (${task.agentId || "main"})\n` +
363
+ `taskId: ${task.taskId?.slice(0, 8) || "unknown"}\n` +
364
+ `error: ${result.error}\n` +
365
+ `time: ${(durationMs / 1000).toFixed(1)}s`
366
+ );
367
+ }
368
+
369
+ const errPath = path.join(ERROR, `${task.taskId}.json`);
370
+ await fsp.writeFile(errPath, JSON.stringify(result, null, 2) + "\n", "utf-8");
371
+ await fsp.unlink(processingPath).catch(() => {});
372
+ }
373
+ }
374
+
375
+ async function drainInboxOnce() {
376
+ ensureDirs();
377
+ const files = (await fsp.readdir(INBOX)).filter((f) => f.endsWith(".json")).sort();
378
+ for (const f of files) {
379
+ await processTaskFile(path.join(INBOX, f));
380
+ }
381
+ }
382
+
383
+ async function main() {
384
+ ensureDirs();
385
+ await drainInboxOnce();
386
+ if (ONCE) return;
387
+
388
+ const watcher = fs.watch(INBOX, async (_eventType, filename) => {
389
+ if (!filename || !filename.endsWith(".json")) return;
390
+ const full = path.join(INBOX, filename);
391
+ setTimeout(() => {
392
+ processTaskFile(full).catch(() => {});
393
+ }, 150);
394
+ });
395
+
396
+ let alive = true;
397
+ const shutdown = () => {
398
+ alive = false;
399
+ watcher.close();
400
+ };
401
+ process.on("SIGINT", shutdown);
402
+ process.on("SIGTERM", shutdown);
403
+
404
+ while (alive) {
405
+ await new Promise((r) => setTimeout(r, 5_000));
406
+ }
407
+ }
408
+
409
+ main().catch((err) => {
410
+ console.error(String(err));
411
+ process.exit(1);
412
+ });
@@ -0,0 +1,20 @@
1
+ [Unit]
2
+ Description=OpenClaw Lesson Extract Worker (Gemini Map-Reduce -> LanceDB Pro)
3
+ After=network-online.target
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ Type=simple
8
+ WorkingDirectory=/home/ubuntu/clawd
9
+ EnvironmentFile=%h/.config/lesson-extract-worker.env
10
+ Environment=NODE_ENV=production
11
+ Environment=LESSON_QUEUE_ROOT=/home/ubuntu/clawd/workspaces/main/tasks/lesson-extract
12
+ Environment=GEMINI_MODEL=gemini-3-flash-preview
13
+ ExecStart=/usr/bin/env node /home/ubuntu/clawd/scripts/lesson-extract-worker.mjs
14
+ Restart=always
15
+ RestartSec=2
16
+ NoNewPrivileges=true
17
+ PrivateTmp=true
18
+
19
+ [Install]
20
+ WantedBy=default.target
package/index.ts CHANGED
@@ -639,32 +639,53 @@ const memoryLanceDBProPlugin = {
639
639
  api.registerService({
640
640
  id: "memory-lancedb-pro",
641
641
  start: async () => {
642
- try {
643
- // Test components
644
- const embedTest = await embedder.test();
645
- const retrievalTest = await retriever.test();
646
-
647
- api.logger.info(
648
- `memory-lancedb-pro: initialized successfully ` +
649
- `(embedding: ${embedTest.success ? 'OK' : 'FAIL'}, ` +
650
- `retrieval: ${retrievalTest.success ? 'OK' : 'FAIL'}, ` +
651
- `mode: ${retrievalTest.mode}, ` +
652
- `FTS: ${retrievalTest.hasFtsSupport ? 'enabled' : 'disabled'})`
653
- );
654
-
655
- if (!embedTest.success) {
656
- api.logger.warn(`memory-lancedb-pro: embedding test failed: ${embedTest.error}`);
642
+ // IMPORTANT: Do not block gateway startup on external network calls.
643
+ // If embedding/retrieval tests hang (bad network / slow provider), the gateway
644
+ // may never bind its HTTP port, causing restart timeouts.
645
+
646
+ const withTimeout = async <T>(p: Promise<T>, ms: number, label: string): Promise<T> => {
647
+ let timeout: ReturnType<typeof setTimeout> | undefined;
648
+ const timeoutPromise = new Promise<never>((_, reject) => {
649
+ timeout = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
650
+ });
651
+ try {
652
+ return await Promise.race([p, timeoutPromise]);
653
+ } finally {
654
+ if (timeout) clearTimeout(timeout);
657
655
  }
658
- if (!retrievalTest.success) {
659
- api.logger.warn(`memory-lancedb-pro: retrieval test failed: ${retrievalTest.error}`);
656
+ };
657
+
658
+ const runStartupChecks = async () => {
659
+ try {
660
+ // Test components (bounded time)
661
+ const embedTest = await withTimeout(embedder.test(), 8_000, "embedder.test()");
662
+ const retrievalTest = await withTimeout(retriever.test(), 8_000, "retriever.test()");
663
+
664
+ api.logger.info(
665
+ `memory-lancedb-pro: initialized successfully ` +
666
+ `(embedding: ${embedTest.success ? "OK" : "FAIL"}, ` +
667
+ `retrieval: ${retrievalTest.success ? "OK" : "FAIL"}, ` +
668
+ `mode: ${retrievalTest.mode}, ` +
669
+ `FTS: ${retrievalTest.hasFtsSupport ? "enabled" : "disabled"})`
670
+ );
671
+
672
+ if (!embedTest.success) {
673
+ api.logger.warn(`memory-lancedb-pro: embedding test failed: ${embedTest.error}`);
674
+ }
675
+ if (!retrievalTest.success) {
676
+ api.logger.warn(`memory-lancedb-pro: retrieval test failed: ${retrievalTest.error}`);
677
+ }
678
+ } catch (error) {
679
+ api.logger.warn(`memory-lancedb-pro: startup checks failed: ${String(error)}`);
660
680
  }
681
+ };
661
682
 
662
- // Run initial backup after a short delay, then schedule daily
663
- setTimeout(() => runBackup(), 60_000); // 1 min after start
664
- backupTimer = setInterval(() => runBackup(), BACKUP_INTERVAL_MS);
665
- } catch (error) {
666
- api.logger.warn(`memory-lancedb-pro: startup test failed: ${String(error)}`);
667
- }
683
+ // Fire-and-forget: allow gateway to start serving immediately.
684
+ setTimeout(() => void runStartupChecks(), 0);
685
+
686
+ // Run initial backup after a short delay, then schedule daily
687
+ setTimeout(() => void runBackup(), 60_000); // 1 min after start
688
+ backupTimer = setInterval(() => void runBackup(), BACKUP_INTERVAL_MS);
668
689
  },
669
690
  stop: () => {
670
691
  if (backupTimer) {
@@ -2,7 +2,7 @@
2
2
  "id": "memory-lancedb-pro",
3
3
  "name": "Memory (LanceDB Pro)",
4
4
  "description": "Enhanced LanceDB-backed long-term memory with hybrid retrieval, multi-scope isolation, and management CLI",
5
- "version": "1.0.8",
5
+ "version": "1.0.10",
6
6
  "kind": "memory",
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -108,7 +108,7 @@
108
108
  },
109
109
  "rerankModel": {
110
110
  "type": "string",
111
- "default": "jina-reranker-v2-base-multilingual",
111
+ "default": "jina-reranker-v3",
112
112
  "description": "Reranker model name"
113
113
  },
114
114
  "rerankEndpoint": {
@@ -304,8 +304,8 @@
304
304
  },
305
305
  "retrieval.rerankModel": {
306
306
  "label": "Reranker Model",
307
- "placeholder": "jina-reranker-v2-base-multilingual",
308
- "help": "Reranker model name (e.g. jina-reranker-v2-base-multilingual, BAAI/bge-reranker-v2-m3)",
307
+ "placeholder": "jina-reranker-v3",
308
+ "help": "Reranker model name (e.g. jina-reranker-v3, BAAI/bge-reranker-v2-m3)",
309
309
  "advanced": true
310
310
  },
311
311
  "retrieval.rerankEndpoint": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-lancedb-pro",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "OpenClaw enhanced LanceDB memory plugin with hybrid retrieval (Vector + BM25), cross-encoder rerank, multi-scope isolation, and management CLI",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/src/retriever.ts CHANGED
@@ -26,7 +26,7 @@ export interface RetrievalConfig {
26
26
  filterNoise: boolean;
27
27
  /** Reranker API key (enables cross-encoder reranking) */
28
28
  rerankApiKey?: string;
29
- /** Reranker model (default: jina-reranker-v2-base-multilingual) */
29
+ /** Reranker model (default: jina-reranker-v3) */
30
30
  rerankModel?: string;
31
31
  /** Reranker API endpoint (default: https://api.jina.ai/v1/rerank). */
32
32
  rerankEndpoint?: string;
@@ -90,7 +90,7 @@ export const DEFAULT_RETRIEVAL_CONFIG: RetrievalConfig = {
90
90
  recencyHalfLifeDays: 14,
91
91
  recencyWeight: 0.10,
92
92
  filterNoise: true,
93
- rerankModel: "jina-reranker-v2-base-multilingual",
93
+ rerankModel: "jina-reranker-v3",
94
94
  rerankEndpoint: "https://api.jina.ai/v1/rerank",
95
95
  lengthNormAnchor: 500,
96
96
  hardMinScore: 0.35,
@@ -427,7 +427,7 @@ export class MemoryRetriever {
427
427
  if (this.config.rerank === "cross-encoder" && this.config.rerankApiKey) {
428
428
  try {
429
429
  const provider = this.config.rerankProvider || "jina";
430
- const model = this.config.rerankModel || "jina-reranker-v2-base-multilingual";
430
+ const model = this.config.rerankModel || "jina-reranker-v3";
431
431
  const endpoint = this.config.rerankEndpoint || "https://api.jina.ai/v1/rerank";
432
432
  const documents = results.map(r => r.entry.text);
433
433