@memtensor/memos-local-openclaw-plugin 1.0.6-beta.1 → 1.0.6-beta.11

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 (46) hide show
  1. package/dist/client/connector.d.ts.map +1 -1
  2. package/dist/client/connector.js +10 -4
  3. package/dist/client/connector.js.map +1 -1
  4. package/dist/hub/server.d.ts +2 -0
  5. package/dist/hub/server.d.ts.map +1 -1
  6. package/dist/hub/server.js +108 -54
  7. package/dist/hub/server.js.map +1 -1
  8. package/dist/ingest/providers/index.d.ts +4 -0
  9. package/dist/ingest/providers/index.d.ts.map +1 -1
  10. package/dist/ingest/providers/index.js +20 -4
  11. package/dist/ingest/providers/index.js.map +1 -1
  12. package/dist/ingest/providers/openai.d.ts +0 -3
  13. package/dist/ingest/providers/openai.d.ts.map +1 -1
  14. package/dist/ingest/providers/openai.js +9 -8
  15. package/dist/ingest/providers/openai.js.map +1 -1
  16. package/dist/recall/engine.d.ts.map +1 -1
  17. package/dist/recall/engine.js +35 -43
  18. package/dist/recall/engine.js.map +1 -1
  19. package/dist/storage/sqlite.d.ts +13 -0
  20. package/dist/storage/sqlite.d.ts.map +1 -1
  21. package/dist/storage/sqlite.js +43 -1
  22. package/dist/storage/sqlite.js.map +1 -1
  23. package/dist/update-check.d.ts.map +1 -1
  24. package/dist/update-check.js +2 -7
  25. package/dist/update-check.js.map +1 -1
  26. package/dist/viewer/html.d.ts.map +1 -1
  27. package/dist/viewer/html.js +115 -27
  28. package/dist/viewer/html.js.map +1 -1
  29. package/dist/viewer/server.d.ts +1 -0
  30. package/dist/viewer/server.d.ts.map +1 -1
  31. package/dist/viewer/server.js +191 -96
  32. package/dist/viewer/server.js.map +1 -1
  33. package/index.ts +208 -253
  34. package/openclaw.plugin.json +1 -1
  35. package/package.json +2 -1
  36. package/scripts/native-binding.cjs +32 -0
  37. package/scripts/postinstall.cjs +13 -16
  38. package/src/client/connector.ts +10 -4
  39. package/src/hub/server.ts +95 -51
  40. package/src/ingest/providers/index.ts +26 -18
  41. package/src/ingest/providers/openai.ts +5 -4
  42. package/src/recall/engine.ts +34 -41
  43. package/src/storage/sqlite.ts +43 -1
  44. package/src/update-check.ts +2 -7
  45. package/src/viewer/html.ts +115 -27
  46. package/src/viewer/server.ts +187 -64
@@ -4,6 +4,7 @@
4
4
  const { spawnSync } = require("child_process");
5
5
  const path = require("path");
6
6
  const fs = require("fs");
7
+ const { validateNativeBinding } = require("./native-binding.cjs");
7
8
 
8
9
  const RESET = "\x1b[0m";
9
10
  const GREEN = "\x1b[32m";
@@ -377,35 +378,31 @@ function findSqliteBinding() {
377
378
 
378
379
  function sqliteBindingsExist() {
379
380
  const found = findSqliteBinding();
380
- if (found) {
381
- log(`Native binding found: ${DIM}${found}${RESET}`);
382
- return true;
381
+ if (!found) return false;
382
+ log(`Native binding found: ${DIM}${found}${RESET}`);
383
+ const status = validateNativeBinding(found);
384
+ if (status.ok) return true;
385
+ if (status.reason === "node-module-version") {
386
+ warn("Native binding exists but was compiled for a different Node.js version.");
387
+ } else {
388
+ warn("Native binding exists but failed to load.");
383
389
  }
390
+ warn(`${DIM}${status.message}${RESET}`);
384
391
  return false;
385
392
  }
386
393
 
387
394
  if (sqliteBindingsExist()) {
388
395
  ok("better-sqlite3 is ready.");
389
396
  } else {
390
- warn("better-sqlite3 native bindings not found in plugin dir.");
397
+ warn("better-sqlite3 native bindings are missing or not loadable.");
391
398
  log(`Searched in: ${DIM}${sqliteModulePath}/build/${RESET}`);
392
399
  log("Running: npm rebuild better-sqlite3 (may take 30-60s)...");
393
- }
394
-
395
- const startMs = Date.now();
396
-
397
- const result = spawnSync(npmCmd, ["rebuild", "better-sqlite3"], {
398
- cwd: pluginDir,
399
- stdio: "pipe",
400
- shell: false,
401
- timeout: 180_000,
402
- });
403
400
 
404
401
  const startMs = Date.now();
405
- const result = spawnSync("npm", ["rebuild", "better-sqlite3"], {
402
+ const result = spawnSync(npmCmd, ["rebuild", "better-sqlite3"], {
406
403
  cwd: pluginDir,
407
404
  stdio: "pipe",
408
- shell: true,
405
+ shell: false,
409
406
  timeout: 180_000,
410
407
  });
411
408
  const elapsed = ((Date.now() - startMs) / 1000).toFixed(1);
@@ -229,22 +229,28 @@ export async function getHubStatus(store: SqliteStore, config: MemosLocalConfig)
229
229
  body: JSON.stringify({ teamToken, userId: conn.userId }),
230
230
  }) as any;
231
231
  if (regResult.status === "active" && regResult.userToken) {
232
- store.setClientHubConnection({
232
+ const updatedConn = {
233
233
  ...conn,
234
234
  hubUrl: normalizeHubUrl(hubAddress),
235
235
  userToken: regResult.userToken,
236
236
  connectedAt: Date.now(),
237
237
  lastKnownStatus: "active",
238
- });
238
+ };
239
+ store.setClientHubConnection(updatedConn);
239
240
  try {
240
241
  const me = await hubRequestJson(normalizeHubUrl(hubAddress), regResult.userToken, "/api/v1/hub/me", { method: "GET" }) as any;
242
+ const latestUsername = String(me.username ?? "");
243
+ const latestRole = String(me.role ?? "member") as UserRole;
244
+ if (latestUsername !== conn.username || latestRole !== conn.role) {
245
+ store.setClientHubConnection({ ...updatedConn, username: latestUsername, role: latestRole });
246
+ }
241
247
  return {
242
248
  connected: true,
243
249
  hubUrl: normalizeHubUrl(hubAddress),
244
250
  user: {
245
251
  id: String(me.id),
246
- username: String(me.username ?? ""),
247
- role: String(me.role ?? "member") as UserRole,
252
+ username: latestUsername,
253
+ role: latestRole,
248
254
  status: String(me.status ?? "active"),
249
255
  groups: Array.isArray(me.groups) ? me.groups : [],
250
256
  },
package/src/hub/server.ts CHANGED
@@ -124,6 +124,8 @@ export class HubServer {
124
124
  this.initOnlineTracking();
125
125
  this.offlineCheckTimer = setInterval(() => this.checkOfflineUsers(), HubServer.OFFLINE_CHECK_INTERVAL_MS);
126
126
 
127
+ this.backfillMemoryEmbeddings();
128
+
127
129
  return `http://127.0.0.1:${hubPort}`;
128
130
  }
129
131
 
@@ -226,8 +228,47 @@ export class HubServer {
226
228
  });
227
229
  }
228
230
 
229
- // Hub memory embeddings are now computed on-the-fly at search time (two-stage retrieval)
230
- // rather than cached in hub_memory_embeddings table, so no embedMemoryAsync needed.
231
+ private backfillMemoryEmbeddings(): void {
232
+ if (!this.opts.embedder) return;
233
+ try {
234
+ const all = this.opts.store.listHubMemories({ limit: 500 });
235
+ const missing = all.filter(m => {
236
+ try { return !this.opts.store.getHubMemoryEmbedding(m.id); } catch { return true; }
237
+ });
238
+ if (missing.length === 0) return;
239
+ this.opts.log.info(`hub: backfilling embeddings for ${missing.length} hub memories`);
240
+ const texts = missing.map(m => (m.summary || m.content || "").slice(0, 500));
241
+ this.opts.embedder.embed(texts).then((vectors) => {
242
+ let count = 0;
243
+ for (let i = 0; i < vectors.length; i++) {
244
+ if (vectors[i]) {
245
+ this.opts.store.upsertHubMemoryEmbedding(missing[i].id, new Float32Array(vectors[i]));
246
+ count++;
247
+ }
248
+ }
249
+ this.opts.log.info(`hub: backfilled ${count}/${missing.length} memory embeddings`);
250
+ }).catch((err) => {
251
+ this.opts.log.warn(`hub: backfill memory embeddings failed: ${err}`);
252
+ });
253
+ } catch (err) {
254
+ this.opts.log.warn(`hub: backfill memory embeddings error: ${err}`);
255
+ }
256
+ }
257
+
258
+ private embedMemoryAsync(memoryId: string, summary: string, content: string): void {
259
+ const embedder = this.opts.embedder;
260
+ if (!embedder) return;
261
+ const text = (summary || content || "").slice(0, 500);
262
+ if (!text) return;
263
+ embedder.embed([text]).then((vectors) => {
264
+ if (vectors[0]) {
265
+ this.opts.store.upsertHubMemoryEmbedding(memoryId, new Float32Array(vectors[0]));
266
+ this.opts.log.info(`hub: embedded shared memory ${memoryId}`);
267
+ }
268
+ }).catch((err) => {
269
+ this.opts.log.warn(`hub: embedding shared memory failed: ${err}`);
270
+ });
271
+ }
231
272
 
232
273
  private async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
233
274
  const url = new URL(req.url || "/", `http://127.0.0.1:${this.port}`);
@@ -253,59 +294,64 @@ export class HubServer {
253
294
  || (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
254
295
  || req.socket.remoteAddress || "";
255
296
  const identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : "";
297
+ const dryRun = body.dryRun === true;
256
298
 
257
- let existingUser = identityKey
299
+ const identityMatch = identityKey
258
300
  ? this.userManager.findByIdentityKey(identityKey)
259
301
  : null;
260
- if (!existingUser) {
261
- const existingUsers = this.opts.store.listHubUsers();
262
- existingUser = existingUsers.find(u => u.username === username && u.status !== "left" && u.status !== "removed") ?? null;
263
- }
264
302
 
265
- if (existingUser) {
266
- try { this.opts.store.updateHubUserActivity(existingUser.id, joinIp); } catch { /* best-effort */ }
303
+ if (identityMatch) {
304
+ if (!dryRun) {
305
+ try { this.opts.store.updateHubUserActivity(identityMatch.id, joinIp); } catch { /* best-effort */ }
306
+ }
267
307
 
268
- if (existingUser.status === "active") {
308
+ if (identityMatch.status === "active") {
309
+ if (dryRun) return this.json(res, 200, { status: "active", dryRun: true });
269
310
  const token = issueUserToken(
270
- { userId: existingUser.id, username: existingUser.username, role: existingUser.role, status: "active" },
311
+ { userId: identityMatch.id, username: identityMatch.username, role: identityMatch.role, status: "active" },
271
312
  this.authSecret,
272
313
  );
273
- this.userManager.approveUser(existingUser.id, token);
274
- if (identityKey && !existingUser.identityKey) {
275
- this.opts.store.upsertHubUser({ ...existingUser, identityKey });
276
- }
277
- return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token, identityKey: existingUser.identityKey || identityKey });
314
+ this.userManager.approveUser(identityMatch.id, token);
315
+ return this.json(res, 200, { status: "active", userId: identityMatch.id, userToken: token, identityKey: identityMatch.identityKey || identityKey });
278
316
  }
279
- if (existingUser.status === "pending") {
280
- this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
281
- return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
317
+ if (identityMatch.status === "pending") {
318
+ if (dryRun) return this.json(res, 200, { status: "pending", dryRun: true });
319
+ this.notifyAdmins("user_join_request", "user", identityMatch.username, "", { dedup: true });
320
+ return this.json(res, 200, { status: "pending", userId: identityMatch.id, identityKey: identityMatch.identityKey || identityKey });
282
321
  }
283
- if (existingUser.status === "rejected") {
322
+ if (identityMatch.status === "rejected") {
323
+ if (dryRun) return this.json(res, 200, { status: "rejected", dryRun: true });
284
324
  if (body.reapply === true) {
285
- this.userManager.resetToPending(existingUser.id);
286
- this.notifyAdmins("user_join_request", "user", username, "");
287
- this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
288
- return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
325
+ this.userManager.resetToPending(identityMatch.id);
326
+ this.notifyAdmins("user_join_request", "user", identityMatch.username, "");
327
+ this.opts.log.info(`Hub: rejected user "${identityMatch.username}" (${identityMatch.id}) re-applied, reset to pending`);
328
+ return this.json(res, 200, { status: "pending", userId: identityMatch.id, identityKey: identityMatch.identityKey || identityKey });
289
329
  }
290
- return this.json(res, 200, { status: "rejected", userId: existingUser.id });
291
- }
292
- if (existingUser.status === "removed") {
293
- this.userManager.rejoinUser(existingUser.id);
294
- this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
295
- this.opts.log.info(`Hub: removed user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
296
- return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
330
+ return this.json(res, 200, { status: "rejected", userId: identityMatch.id });
297
331
  }
298
- if (existingUser.status === "left") {
299
- this.userManager.rejoinUser(existingUser.id);
300
- this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
301
- this.opts.log.info(`Hub: left user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
302
- return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
332
+ if (identityMatch.status === "removed" || identityMatch.status === "left") {
333
+ if (dryRun) return this.json(res, 200, { status: "can_rejoin", dryRun: true });
334
+ this.userManager.rejoinUser(identityMatch.id);
335
+ this.notifyAdmins("user_join_request", "user", identityMatch.username, "", { dedup: true });
336
+ this.opts.log.info(`Hub: ${identityMatch.status} user "${identityMatch.username}" (${identityMatch.id}) re-applied via rejoin, reset to pending`);
337
+ return this.json(res, 200, { status: "pending", userId: identityMatch.id, identityKey: identityMatch.identityKey || identityKey });
303
338
  }
304
- if (existingUser.status === "blocked") {
305
- return this.json(res, 200, { status: "blocked", userId: existingUser.id });
339
+ if (identityMatch.status === "blocked") {
340
+ return this.json(res, 200, { status: "blocked", userId: identityMatch.id });
306
341
  }
307
342
  }
308
343
 
344
+ const existingUsers = this.opts.store.listHubUsers();
345
+ const nameConflict = existingUsers.find(u => u.username === username);
346
+ if (nameConflict) {
347
+ this.opts.log.info(`Hub: join rejected — username "${username}" already taken by user ${nameConflict.id} (status=${nameConflict.status})`);
348
+ return this.json(res, 409, { error: "username_taken", message: `Username "${username}" is already in use. Please choose a different nickname.` });
349
+ }
350
+
351
+ if (dryRun) {
352
+ return this.json(res, 200, { status: "ok", dryRun: true });
353
+ }
354
+
309
355
  const generatedIdentityKey = identityKey || randomUUID();
310
356
  const user = this.userManager.createPendingUser({
311
357
  username,
@@ -534,6 +580,12 @@ export class HubServer {
534
580
  this.authState.bootstrapAdminToken = newToken;
535
581
  this.saveAuthState();
536
582
  }
583
+ try {
584
+ this.opts.store.insertHubNotification({
585
+ id: randomUUID(), userId, type: "username_renamed",
586
+ resource: "user", title: `Your nickname has been changed from "${user.username}" to "${newUsername}" by the admin.`,
587
+ });
588
+ } catch { /* best-effort */ }
537
589
  this.opts.log.info(`Hub: admin "${auth.userId}" renamed user "${userId}" to "${newUsername}"`);
538
590
  return this.json(res, 200, { ok: true, username: newUsername });
539
591
  }
@@ -615,7 +667,7 @@ export class HubServer {
615
667
  createdAt: existing?.createdAt ?? now,
616
668
  updatedAt: now,
617
669
  });
618
- // No embedding on share hub memory vectors are computed on-the-fly at search time
670
+ this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
619
671
  if (!existing) {
620
672
  this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
621
673
  }
@@ -680,18 +732,10 @@ export class HubServer {
680
732
  };
681
733
  for (const e of allEmb) scored.push({ id: e.chunkId, score: cosineSim(e.vector, queryVec) });
682
734
 
683
- // Hub memories: embed FTS candidates on-the-fly instead of reading cached vectors
684
- if (memFtsHits.length > 0) {
685
- const memTexts = memFtsHits.map(({ hit }) => (hit.summary || hit.content || "").slice(0, 500));
686
- try {
687
- const memVecs = await this.opts.embedder.embed(memTexts);
688
- memFtsHits.forEach(({ hit }, i) => {
689
- if (memVecs[i]) {
690
- scored.push({ id: hit.id, score: cosineSim(new Float32Array(memVecs[i]), queryVec) });
691
- memoryIdSet.add(hit.id);
692
- }
693
- });
694
- } catch { /* best-effort */ }
735
+ const memEmb = this.opts.store.getVisibleHubMemoryEmbeddings(auth.userId);
736
+ for (const e of memEmb) {
737
+ scored.push({ id: e.memoryId, score: cosineSim(e.vector, queryVec) });
738
+ memoryIdSet.add(e.memoryId);
695
739
  }
696
740
 
697
741
  scored.sort((a, b) => b.score - a.score);
@@ -1,20 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import type { SummarizerConfig, SummaryProvider, Logger, OpenClawAPI } from "../../types";
4
- import {
5
- summarizeOpenAI,
6
- summarizeTaskOpenAI,
7
- generateTaskTitleOpenAI,
8
- judgeNewTopicOpenAI,
9
- filterRelevantOpenAI,
10
- judgeDedupOpenAI,
11
- parseFilterResult,
12
- parseDedupResult,
13
- TASK_SUMMARY_PROMPT,
14
- TOPIC_JUDGE_PROMPT,
15
- FILTER_RELEVANT_PROMPT,
16
- DEDUP_JUDGE_PROMPT,
17
- } from "./openai";
4
+ import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, filterRelevantOpenAI, judgeDedupOpenAI, parseFilterResult, parseDedupResult } from "./openai";
18
5
  import type { FilterResult, DedupResult } from "./openai";
19
6
  export type { FilterResult, DedupResult } from "./openai";
20
7
  import { summarizeAnthropic, summarizeTaskAnthropic, generateTaskTitleAnthropic, judgeNewTopicAnthropic, filterRelevantAnthropic, judgeDedupAnthropic } from "./anthropic";
@@ -342,6 +329,27 @@ export class Summarizer {
342
329
  return this.strongCfg;
343
330
  }
344
331
 
332
+ // ─── OpenClaw Prompts ───
333
+
334
+ static readonly OPENCLAW_TOPIC_JUDGE_PROMPT = `You are a conversation topic change detector.
335
+ Given a CURRENT CONVERSATION SUMMARY and a NEW USER MESSAGE, decide: has the user started a COMPLETELY NEW topic that is unrelated to the current conversation?
336
+ Reply with a single word: "NEW" if topic changed, "SAME" if it continues.`;
337
+
338
+ static readonly OPENCLAW_FILTER_RELEVANT_PROMPT = `You are a memory relevance judge.
339
+ Given a QUERY and CANDIDATE memories, decide: does each candidate help answer the query?
340
+ RULES:
341
+ 1. Include candidates whose content provides useful facts/context for the query.
342
+ 2. Exclude candidates that merely share a topic but contain no useful information.
343
+ 3. DEDUPLICATION: When multiple candidates convey the same or very similar information, keep ONLY the most complete one and exclude the rest.
344
+ 4. If none help, return {"relevant":[],"sufficient":false}.
345
+ OUTPUT — JSON only: {"relevant":[1,3],"sufficient":true}`;
346
+
347
+ static readonly OPENCLAW_DEDUP_JUDGE_PROMPT = `You are a memory deduplication system.
348
+ Given a NEW memory summary and EXISTING candidates, decide if the new memory duplicates any existing one.
349
+ Reply with JSON: {"action":"MERGE","mergeTarget":2,"reason":"..."} or {"action":"NEW","reason":"..."}`;
350
+
351
+ static readonly OPENCLAW_TASK_SUMMARY_PROMPT = `Summarize the following task conversation into a structured report. Preserve key decisions, code, commands, and outcomes. Use the same language as the input.`;
352
+
345
353
  // ─── OpenClaw API Implementation ───
346
354
 
347
355
  private requireOpenClawAPI(): void {
@@ -373,7 +381,7 @@ export class Summarizer {
373
381
  private async summarizeTaskOpenClaw(text: string): Promise<string> {
374
382
  this.requireOpenClawAPI();
375
383
  const prompt = [
376
- TASK_SUMMARY_PROMPT,
384
+ Summarizer.OPENCLAW_TASK_SUMMARY_PROMPT,
377
385
  ``,
378
386
  text,
379
387
  ].join("\n");
@@ -391,7 +399,7 @@ export class Summarizer {
391
399
  private async judgeNewTopicOpenClaw(currentContext: string, newMessage: string): Promise<boolean> {
392
400
  this.requireOpenClawAPI();
393
401
  const prompt = [
394
- TOPIC_JUDGE_PROMPT,
402
+ Summarizer.OPENCLAW_TOPIC_JUDGE_PROMPT,
395
403
  ``,
396
404
  `CURRENT CONVERSATION SUMMARY:`,
397
405
  currentContext,
@@ -422,7 +430,7 @@ export class Summarizer {
422
430
  .join("\n");
423
431
 
424
432
  const prompt = [
425
- FILTER_RELEVANT_PROMPT,
433
+ Summarizer.OPENCLAW_FILTER_RELEVANT_PROMPT,
426
434
  ``,
427
435
  `QUERY: ${query}`,
428
436
  ``,
@@ -450,7 +458,7 @@ export class Summarizer {
450
458
  .join("\n");
451
459
 
452
460
  const prompt = [
453
- DEDUP_JUDGE_PROMPT,
461
+ Summarizer.OPENCLAW_DEDUP_JUDGE_PROMPT,
454
462
  ``,
455
463
  `NEW MEMORY:`,
456
464
  newSummary,
@@ -16,7 +16,7 @@ Requirements:
16
16
  - Non-Chinese: 5-15 words (aim for 8-12)
17
17
  - Output title only`;
18
18
 
19
- export const TASK_SUMMARY_PROMPT = `You create a DETAILED task summary from a multi-turn conversation. This summary will be the ONLY record of this conversation, so it must preserve ALL important information.
19
+ const TASK_SUMMARY_PROMPT = `You create a DETAILED task summary from a multi-turn conversation. This summary will be the ONLY record of this conversation, so it must preserve ALL important information.
20
20
 
21
21
  ## LANGUAGE RULE (HIGHEST PRIORITY)
22
22
  Detect the PRIMARY language of the user's messages. If most user messages are Chinese, ALL output (title, goal, steps, result, details) MUST be in Chinese. If English, output in English. NEVER mix. This rule overrides everything below.
@@ -178,7 +178,7 @@ export async function summarizeOpenAI(
178
178
  return json.choices[0]?.message?.content?.trim() ?? "";
179
179
  }
180
180
 
181
- export const TOPIC_JUDGE_PROMPT = `You are a conversation topic boundary detector. Given the CURRENT task context and a NEW user message, decide if the new message belongs to the SAME task or starts a NEW one.
181
+ const TOPIC_JUDGE_PROMPT = `You are a conversation topic boundary detector. Given the CURRENT task context and a NEW user message, decide if the new message belongs to the SAME task or starts a NEW one.
182
182
 
183
183
  Answer ONLY "NEW" or "SAME".
184
184
 
@@ -246,7 +246,7 @@ export async function judgeNewTopicOpenAI(
246
246
  return answer.startsWith("NEW");
247
247
  }
248
248
 
249
- export const FILTER_RELEVANT_PROMPT = `You are a memory relevance judge.
249
+ const FILTER_RELEVANT_PROMPT = `You are a memory relevance judge.
250
250
 
251
251
  Given a QUERY and CANDIDATE memories, decide: does each candidate's content contain information that would HELP ANSWER the query?
252
252
 
@@ -258,10 +258,11 @@ RULES:
258
258
  1. A candidate is relevant if its content provides facts, context, or data that directly supports answering the query.
259
259
  2. A candidate that merely shares the same broad topic/domain but contains NO useful information for answering is NOT relevant.
260
260
  3. If NO candidate can help answer the query, return {"relevant":[],"sufficient":false} — do NOT force-pick the "least irrelevant" one.
261
+ 4. DEDUPLICATION: When multiple candidates convey the same or very similar information, keep ONLY the most complete/detailed one and exclude the rest. Do NOT return near-duplicate snippets.
261
262
 
262
263
  OUTPUT — JSON only:
263
264
  {"relevant":[1,3],"sufficient":true}
264
- - "relevant": candidate numbers whose content helps answer the query. [] if none can help.
265
+ - "relevant": candidate numbers whose content helps answer the query. [] if none can help. Duplicates removed — only unique information.
265
266
  - "sufficient": true only if the selected memories fully answer the query.`;
266
267
 
267
268
  export interface FilterResult {
@@ -62,10 +62,20 @@ export class RecallEngine {
62
62
 
63
63
  // Step 1b: Pattern search (LIKE-based) as fallback for short terms that
64
64
  // trigram FTS cannot match (trigram requires >= 3 chars).
65
- const shortTerms = query
66
- .replace(/[."""(){}[\]*:^~!@#$%&\\/<>,;'`??。,!、:""''()【】《》]/g, " ")
67
- .split(/\s+/)
68
- .filter((t) => t.length === 2);
65
+ // For CJK text without spaces, extract bigrams (2-char sliding windows)
66
+ // so that queries like "唐波是谁" produce ["唐波", "波是", "是谁"].
67
+ const cleaned = query.replace(/[."""(){}[\]*:^~!@#$%&\\/<>,;'`??。,!、:""''()【】《》]/g, " ");
68
+ const spaceSplit = cleaned.split(/\s+/).filter((t) => t.length === 2);
69
+ const cjkBigrams: string[] = [];
70
+ const cjkRuns = cleaned.match(/[\u4e00-\u9fff\u3400-\u4dbf\uF900-\uFAFF]{2,}/g);
71
+ if (cjkRuns) {
72
+ for (const run of cjkRuns) {
73
+ for (let i = 0; i <= run.length - 2; i++) {
74
+ cjkBigrams.push(run.slice(i, i + 2));
75
+ }
76
+ }
77
+ }
78
+ const shortTerms = [...new Set([...spaceSplit, ...cjkBigrams])];
69
79
  const patternHits = shortTerms.length > 0
70
80
  ? this.store.patternSearch(shortTerms, { limit: candidatePool })
71
81
  : [];
@@ -74,58 +84,41 @@ export class RecallEngine {
74
84
  score: 1 / (i + 1),
75
85
  }));
76
86
 
77
- // Step 1c: Hub memories — two-stage retrieval (no cached embeddings).
78
- // Stage 1: FTS + pattern to get candidates.
79
- // Stage 2: embed candidates on-the-fly + cosine rerank.
87
+ // Step 1c: Hub memories — FTS + pattern + cached embeddings (same strategy as chunks/skills).
80
88
  let hubMemFtsRanked: Array<{ id: string; score: number }> = [];
81
89
  let hubMemVecRanked: Array<{ id: string; score: number }> = [];
82
90
  let hubMemPatternRanked: Array<{ id: string; score: number }> = [];
83
91
  if (query && this.ctx.config.sharing?.enabled && this.ctx.config.sharing.role === "hub") {
84
- // Stage 1: cheap text retrieval
85
- const hubCandidateTexts = new Map<string, string>();
86
92
  try {
87
93
  const hubFtsHits = this.store.searchHubMemories(query, { maxResults: candidatePool });
88
- hubMemFtsRanked = hubFtsHits.map(({ hit }, i) => {
89
- hubCandidateTexts.set(hit.id, (hit.summary || hit.content || "").slice(0, 500));
90
- return { id: `hubmem:${hit.id}`, score: 1 / (i + 1) };
91
- });
94
+ hubMemFtsRanked = hubFtsHits.map(({ hit }, i) => ({ id: `hubmem:${hit.id}`, score: 1 / (i + 1) }));
92
95
  } catch { /* hub_memories table may not exist */ }
93
96
  if (shortTerms.length > 0) {
94
97
  try {
95
98
  const hubPatternHits = this.store.hubMemoryPatternSearch(shortTerms, { limit: candidatePool });
96
- hubMemPatternRanked = hubPatternHits.map((h, i) => {
97
- hubCandidateTexts.set(h.memoryId, (h.content || "").slice(0, 500));
98
- return { id: `hubmem:${h.memoryId}`, score: 1 / (i + 1) };
99
- });
99
+ hubMemPatternRanked = hubPatternHits.map((h, i) => ({ id: `hubmem:${h.memoryId}`, score: 1 / (i + 1) }));
100
100
  } catch { /* best-effort */ }
101
101
  }
102
102
 
103
- // Stage 2: embed candidates on-the-fly and cosine rerank
104
- if (hubCandidateTexts.size > 0) {
105
- try {
106
- const qv = await this.embedder.embedQuery(query).catch(() => null);
107
- if (qv) {
108
- const ids = [...hubCandidateTexts.keys()];
109
- const texts = ids.map(id => hubCandidateTexts.get(id)!);
110
- const vecs = await this.embedder.embed(texts);
111
- const scored: Array<{ id: string; score: number }> = [];
112
- for (let j = 0; j < ids.length; j++) {
113
- if (!vecs[j]) continue;
114
- const v = vecs[j];
115
- let dot = 0, nA = 0, nB = 0;
116
- for (let i = 0; i < qv.length && i < v.length; i++) {
117
- dot += qv[i] * v[i]; nA += qv[i] * qv[i]; nB += v[i] * v[i];
118
- }
119
- const sim = nA > 0 && nB > 0 ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
120
- if (sim > 0.3) {
121
- scored.push({ id: `hubmem:${ids[j]}`, score: sim });
122
- }
103
+ try {
104
+ const qv = await this.embedder.embedQuery(query).catch(() => null);
105
+ if (qv) {
106
+ const memEmbs = this.store.getVisibleHubMemoryEmbeddings("__hub__");
107
+ const scored: Array<{ id: string; score: number }> = [];
108
+ for (const e of memEmbs) {
109
+ let dot = 0, nA = 0, nB = 0;
110
+ const len = Math.min(qv.length, e.vector.length);
111
+ for (let i = 0; i < len; i++) {
112
+ dot += qv[i] * e.vector[i]; nA += qv[i] * qv[i]; nB += e.vector[i] * e.vector[i];
123
113
  }
124
- scored.sort((a, b) => b.score - a.score);
125
- hubMemVecRanked = scored.slice(0, candidatePool);
114
+ const sim = nA > 0 && nB > 0 ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
115
+ if (sim > 0.3) scored.push({ id: `hubmem:${e.memoryId}`, score: sim });
126
116
  }
127
- } catch { /* best-effort */ }
128
- }
117
+ scored.sort((a, b) => b.score - a.score);
118
+ hubMemVecRanked = scored.slice(0, candidatePool);
119
+ }
120
+ } catch { /* best-effort */ }
121
+
129
122
  const hubTotal = hubMemFtsRanked.length + hubMemVecRanked.length + hubMemPatternRanked.length;
130
123
  if (hubTotal > 0) {
131
124
  this.ctx.log.debug(`recall: hub_memories candidates: fts=${hubMemFtsRanked.length}, vec=${hubMemVecRanked.length}, pattern=${hubMemPatternRanked.length}`);
@@ -1005,7 +1005,12 @@ export class SqliteStore {
1005
1005
  CREATE INDEX IF NOT EXISTS idx_hub_memories_visibility ON hub_memories(visibility);
1006
1006
  CREATE INDEX IF NOT EXISTS idx_hub_memories_group ON hub_memories(group_id);
1007
1007
 
1008
- -- hub_memory_embeddings removed: vectors are now computed on-the-fly at search time
1008
+ CREATE TABLE IF NOT EXISTS hub_memory_embeddings (
1009
+ memory_id TEXT PRIMARY KEY REFERENCES hub_memories(id) ON DELETE CASCADE,
1010
+ vector BLOB NOT NULL,
1011
+ dimensions INTEGER NOT NULL,
1012
+ updated_at INTEGER NOT NULL
1013
+ );
1009
1014
 
1010
1015
  CREATE VIRTUAL TABLE IF NOT EXISTS hub_memories_fts USING fts5(
1011
1016
  summary,
@@ -1252,6 +1257,13 @@ export class SqliteStore {
1252
1257
  } catch { return []; }
1253
1258
  }
1254
1259
 
1260
+ listHubMemories(opts: { limit?: number } = {}): Array<{ id: string; summary?: string; content?: string }> {
1261
+ const limit = opts.limit ?? 200;
1262
+ try {
1263
+ return this.db.prepare("SELECT id, summary, content FROM hub_memories ORDER BY created_at DESC LIMIT ?").all(limit) as Array<{ id: string; summary?: string; content?: string }>;
1264
+ } catch { return []; }
1265
+ }
1266
+
1255
1267
  // ─── Vector Search ───
1256
1268
 
1257
1269
  getAllEmbeddings(ownerFilter?: string[]): Array<{ chunkId: string; vector: number[] }> {
@@ -2194,6 +2206,36 @@ export class SqliteStore {
2194
2206
  }));
2195
2207
  }
2196
2208
 
2209
+ upsertHubMemoryEmbedding(memoryId: string, vector: Float32Array): void {
2210
+ const buf = Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength);
2211
+ this.db.prepare(`
2212
+ INSERT INTO hub_memory_embeddings (memory_id, vector, dimensions, updated_at)
2213
+ VALUES (?, ?, ?, ?)
2214
+ ON CONFLICT(memory_id) DO UPDATE SET vector = excluded.vector, dimensions = excluded.dimensions, updated_at = excluded.updated_at
2215
+ `).run(memoryId, buf, vector.length, Date.now());
2216
+ }
2217
+
2218
+ getHubMemoryEmbedding(memoryId: string): Float32Array | null {
2219
+ const row = this.db.prepare('SELECT vector, dimensions FROM hub_memory_embeddings WHERE memory_id = ?').get(memoryId) as { vector: Buffer; dimensions: number } | undefined;
2220
+ if (!row) return null;
2221
+ return new Float32Array(row.vector.buffer, row.vector.byteOffset, row.dimensions);
2222
+ }
2223
+
2224
+ getVisibleHubMemoryEmbeddings(userId: string): Array<{ memoryId: string; vector: Float32Array }> {
2225
+ const rows = this.db.prepare(`
2226
+ SELECT hme.memory_id, hme.vector, hme.dimensions
2227
+ FROM hub_memory_embeddings hme
2228
+ JOIN hub_memories hm ON hm.id = hme.memory_id
2229
+ WHERE hm.visibility = 'public'
2230
+ OR hm.source_user_id = ?
2231
+ OR EXISTS (SELECT 1 FROM hub_group_members gm WHERE gm.group_id = hm.group_id AND gm.user_id = ?)
2232
+ `).all(userId, userId) as Array<{ memory_id: string; vector: Buffer; dimensions: number }>;
2233
+ return rows.map(r => ({
2234
+ memoryId: r.memory_id,
2235
+ vector: new Float32Array(r.vector.buffer, r.vector.byteOffset, r.dimensions),
2236
+ }));
2237
+ }
2238
+
2197
2239
  searchHubChunks(query: string, options?: { userId?: string; maxResults?: number }): Array<{ hit: HubSearchRow; rank: number }> {
2198
2240
  const limit = options?.maxResults ?? 10;
2199
2241
  const userId = options?.userId ?? "";
@@ -52,26 +52,21 @@ export async function computeUpdateCheck(
52
52
 
53
53
  if (onBeta) {
54
54
  channel = "beta";
55
- // Beta users: only compare against beta tag; never suggest "updating" to stable via gt confusion.
56
55
  if (betaTag && semver.valid(betaTag) && semver.gt(betaTag, current)) {
57
56
  updateAvailable = true;
58
57
  targetVersion = betaTag;
59
- installCommand = `openclaw plugins install ${packageName}@beta`;
60
58
  } else {
61
59
  targetVersion = betaTag && semver.valid(betaTag) ? betaTag : current;
62
- if (betaTag && semver.valid(betaTag) && semver.eq(betaTag, current)) {
63
- installCommand = `openclaw plugins install ${packageName}@beta`;
64
- }
65
60
  }
61
+ installCommand = `openclaw plugins install ${packageName}@${targetVersion}`;
66
62
  } else {
67
- // Stable users: compare against latest only.
68
63
  if (latestTag && semver.valid(latestTag) && semver.gt(latestTag, current)) {
69
64
  updateAvailable = true;
70
65
  targetVersion = latestTag;
71
- installCommand = `openclaw plugins install ${packageName}`;
72
66
  } else {
73
67
  targetVersion = latestTag && semver.valid(latestTag) ? latestTag : current;
74
68
  }
69
+ installCommand = `openclaw plugins install ${packageName}@${targetVersion}`;
75
70
  }
76
71
 
77
72
  // Beta user + stable exists on latest: optional hint to switch to stable (not counted as "update").