@memtensor/memos-local-openclaw-plugin 1.0.5 → 1.0.6-beta.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.
Files changed (66) hide show
  1. package/dist/capture/index.d.ts.map +1 -1
  2. package/dist/capture/index.js +24 -0
  3. package/dist/capture/index.js.map +1 -1
  4. package/dist/client/connector.d.ts.map +1 -1
  5. package/dist/client/connector.js +33 -5
  6. package/dist/client/connector.js.map +1 -1
  7. package/dist/client/hub.d.ts.map +1 -1
  8. package/dist/client/hub.js +4 -0
  9. package/dist/client/hub.js.map +1 -1
  10. package/dist/hub/server.d.ts +2 -0
  11. package/dist/hub/server.d.ts.map +1 -1
  12. package/dist/hub/server.js +116 -54
  13. package/dist/hub/server.js.map +1 -1
  14. package/dist/ingest/providers/index.d.ts +4 -0
  15. package/dist/ingest/providers/index.d.ts.map +1 -1
  16. package/dist/ingest/providers/index.js +32 -86
  17. package/dist/ingest/providers/index.js.map +1 -1
  18. package/dist/ingest/providers/openai.d.ts.map +1 -1
  19. package/dist/ingest/providers/openai.js +29 -13
  20. package/dist/ingest/providers/openai.js.map +1 -1
  21. package/dist/recall/engine.d.ts.map +1 -1
  22. package/dist/recall/engine.js +33 -32
  23. package/dist/recall/engine.js.map +1 -1
  24. package/dist/storage/sqlite.d.ts +43 -7
  25. package/dist/storage/sqlite.d.ts.map +1 -1
  26. package/dist/storage/sqlite.js +179 -58
  27. package/dist/storage/sqlite.js.map +1 -1
  28. package/dist/tools/memory-get.d.ts.map +1 -1
  29. package/dist/tools/memory-get.js +4 -1
  30. package/dist/tools/memory-get.js.map +1 -1
  31. package/dist/types.d.ts +1 -1
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/types.js.map +1 -1
  34. package/dist/update-check.d.ts.map +1 -1
  35. package/dist/update-check.js +2 -7
  36. package/dist/update-check.js.map +1 -1
  37. package/dist/viewer/html.d.ts.map +1 -1
  38. package/dist/viewer/html.js +115 -27
  39. package/dist/viewer/html.js.map +1 -1
  40. package/dist/viewer/server.d.ts +25 -0
  41. package/dist/viewer/server.d.ts.map +1 -1
  42. package/dist/viewer/server.js +503 -206
  43. package/dist/viewer/server.js.map +1 -1
  44. package/index.ts +273 -282
  45. package/openclaw.plugin.json +1 -1
  46. package/package.json +2 -1
  47. package/scripts/native-binding.cjs +32 -0
  48. package/scripts/postinstall.cjs +24 -11
  49. package/src/capture/index.ts +36 -0
  50. package/src/client/connector.ts +32 -5
  51. package/src/client/hub.ts +4 -0
  52. package/src/hub/server.ts +110 -50
  53. package/src/ingest/providers/index.ts +37 -92
  54. package/src/ingest/providers/openai.ts +31 -13
  55. package/src/recall/engine.ts +32 -30
  56. package/src/storage/sqlite.ts +196 -63
  57. package/src/tools/memory-get.ts +4 -1
  58. package/src/types.ts +2 -0
  59. package/src/update-check.ts +2 -7
  60. package/src/viewer/html.ts +115 -27
  61. package/src/viewer/server.ts +483 -172
  62. package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
  63. package/prebuilds/darwin-x64/better_sqlite3.node +0 -0
  64. package/prebuilds/linux-x64/better_sqlite3.node +0 -0
  65. package/prebuilds/win32-x64/better_sqlite3.node +0 -0
  66. package/telemetry.credentials.json +0 -5
@@ -3,7 +3,7 @@
3
3
  "name": "MemOS Local Memory",
4
4
  "description": "Full-write local conversation memory with hybrid search (RRF + MMR + recency), task summarization, skill evolution, and team sharing (Hub-Client). Provides memory_search, memory_get, task_summary, skill_search, task_share, network_skill_pull, network_team_info, memory_viewer for layered retrieval and team collaboration.",
5
5
  "kind": "memory",
6
- "version": "0.1.12",
6
+ "version": "1.0.6-beta.10",
7
7
  "skills": [
8
8
  "skill/memos-memory-guide"
9
9
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memtensor/memos-local-openclaw-plugin",
3
- "version": "1.0.5",
3
+ "version": "1.0.6-beta.10",
4
4
  "description": "MemOS Local memory plugin for OpenClaw — full-write, hybrid-recall, progressive retrieval",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -11,6 +11,7 @@
11
11
  "dist",
12
12
  "skill",
13
13
  "prebuilds",
14
+ "scripts/native-binding.cjs",
14
15
  "scripts/postinstall.cjs",
15
16
  "openclaw.plugin.json",
16
17
  "telemetry.credentials.json",
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+
3
+ function errorMessage(error) {
4
+ if (error && typeof error.message === "string") return error.message;
5
+ return String(error || "Unknown native binding error");
6
+ }
7
+
8
+ function defaultLoadBinding(bindingPath) {
9
+ process.dlopen({ exports: {} }, bindingPath);
10
+ }
11
+
12
+ function validateNativeBinding(bindingPath, loadBinding = defaultLoadBinding) {
13
+ if (!bindingPath) {
14
+ return { ok: false, reason: "missing", message: "Native binding path not found" };
15
+ }
16
+
17
+ try {
18
+ loadBinding(bindingPath);
19
+ return { ok: true, reason: "ok", message: "" };
20
+ } catch (error) {
21
+ const message = errorMessage(error);
22
+ if (/NODE_MODULE_VERSION/.test(message)) {
23
+ return { ok: false, reason: "node-module-version", message };
24
+ }
25
+ return { ok: false, reason: "load-error", message };
26
+ }
27
+ }
28
+
29
+ module.exports = {
30
+ defaultLoadBinding,
31
+ validateNativeBinding,
32
+ };
@@ -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";
@@ -23,6 +24,11 @@ function phase(n, title) {
23
24
  }
24
25
 
25
26
  const pluginDir = path.resolve(__dirname, "..");
27
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
28
+
29
+ function normalizePathForMatch(p) {
30
+ return path.resolve(p).replace(/^\\\\\?\\/, "").replace(/\\/g, "/").toLowerCase();
31
+ }
26
32
 
27
33
  console.log(`
28
34
  ${CYAN}${BOLD}┌──────────────────────────────────────────────────┐
@@ -42,7 +48,8 @@ log(`Node: ${process.version} Platform: ${process.platform}-${process.arch}`);
42
48
  * ═══════════════════════════════════════════════════════════ */
43
49
 
44
50
  function cleanStaleArtifacts() {
45
- const isExtensionsDir = pluginDir.includes(path.join(".openclaw", "extensions"));
51
+ const pluginDirNorm = normalizePathForMatch(pluginDir);
52
+ const isExtensionsDir = pluginDirNorm.includes("/.openclaw/extensions/");
46
53
  if (!isExtensionsDir) return;
47
54
 
48
55
  const pkgPath = path.join(pluginDir, "package.json");
@@ -133,10 +140,10 @@ function ensureDependencies() {
133
140
  log("Running: npm install --omit=dev ...");
134
141
 
135
142
  const startMs = Date.now();
136
- const result = spawnSync("npm", ["install", "--omit=dev"], {
143
+ const result = spawnSync(npmCmd, ["install", "--omit=dev"], {
137
144
  cwd: pluginDir,
138
145
  stdio: "pipe",
139
- shell: true,
146
+ shell: false,
140
147
  timeout: 120_000,
141
148
  });
142
149
  const elapsed = ((Date.now() - startMs) / 1000).toFixed(1);
@@ -223,8 +230,8 @@ function cleanupLegacy() {
223
230
  newEntry.source = oldSource
224
231
  .replace(/memos-lite-openclaw-plugin/g, "memos-local-openclaw-plugin")
225
232
  .replace(/memos-lite/g, "memos-local-openclaw-plugin")
226
- .replace(/\/memos-local\//g, "/memos-local-openclaw-plugin/")
227
- .replace(/\/memos-local$/g, "/memos-local-openclaw-plugin");
233
+ .replace(/[\\/]memos-local[\\/]/g, `${path.sep}memos-local-openclaw-plugin${path.sep}`)
234
+ .replace(/[\\/]memos-local$/g, `${path.sep}memos-local-openclaw-plugin`);
228
235
  if (newEntry.source !== oldSource) {
229
236
  log(`Updated source path: ${DIM}${oldSource}${RESET} → ${GREEN}${newEntry.source}${RESET}`);
230
237
  cfgChanged = true;
@@ -371,25 +378,31 @@ function findSqliteBinding() {
371
378
 
372
379
  function sqliteBindingsExist() {
373
380
  const found = findSqliteBinding();
374
- if (found) {
375
- log(`Native binding found: ${DIM}${found}${RESET}`);
376
- 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.");
377
389
  }
390
+ warn(`${DIM}${status.message}${RESET}`);
378
391
  return false;
379
392
  }
380
393
 
381
394
  if (sqliteBindingsExist()) {
382
395
  ok("better-sqlite3 is ready.");
383
396
  } else {
384
- warn("better-sqlite3 native bindings not found in plugin dir.");
397
+ warn("better-sqlite3 native bindings are missing or not loadable.");
385
398
  log(`Searched in: ${DIM}${sqliteModulePath}/build/${RESET}`);
386
399
  log("Running: npm rebuild better-sqlite3 (may take 30-60s)...");
387
400
 
388
401
  const startMs = Date.now();
389
- const result = spawnSync("npm", ["rebuild", "better-sqlite3"], {
402
+ const result = spawnSync(npmCmd, ["rebuild", "better-sqlite3"], {
390
403
  cwd: pluginDir,
391
404
  stdio: "pipe",
392
- shell: true,
405
+ shell: false,
393
406
  timeout: 180_000,
394
407
  });
395
408
  const elapsed = ((Date.now() - startMs) / 1000).toFixed(1);
@@ -4,6 +4,17 @@ const SKIP_ROLES: Set<Role> = new Set(["system"]);
4
4
 
5
5
  const SYSTEM_BOILERPLATE_RE = /^A new session was started via \/new or \/reset\b/;
6
6
 
7
+ // Boot-check / memory-system injection patterns that should never be stored.
8
+ const BOOT_CHECK_RE = /^(?:You are running a boot check|Read HEARTBEAT\.md if it exists|## Memory system — ACTION REQUIRED)/;
9
+
10
+ /**
11
+ * Returns true for sentinel reply values that carry no user-facing content.
12
+ */
13
+ function isSentinelReply(text: string): boolean {
14
+ const t = text.trim();
15
+ return t === "NO_REPLY" || t === "HEARTBEAT_OK" || t === "HEARTBEAT_CHECK";
16
+ }
17
+
7
18
  const SELF_TOOLS = new Set([
8
19
  "memory_search",
9
20
  "memory_timeline",
@@ -61,6 +72,16 @@ export function captureMessages(
61
72
  if (SKIP_ROLES.has(role)) continue;
62
73
  if (!msg.content || msg.content.trim().length === 0) continue;
63
74
 
75
+ // Skip sentinel replies and boot-check prompts for ALL roles.
76
+ if (isSentinelReply(msg.content)) {
77
+ log.debug(`Skipping sentinel reply`);
78
+ continue;
79
+ }
80
+ if (BOOT_CHECK_RE.test(msg.content.trim())) {
81
+ log.debug(`Skipping boot-check injection: ${msg.content.slice(0, 60)}...`);
82
+ continue;
83
+ }
84
+
64
85
  if (role === "tool" && msg.toolName && SELF_TOOLS.has(msg.toolName)) {
65
86
  log.debug(`Skipping self-tool result: ${msg.toolName}`);
66
87
  continue;
@@ -232,6 +253,21 @@ function stripMemoryInjection(text: string): string {
232
253
  "",
233
254
  ).trim();
234
255
 
256
+ // ## Memory system — ACTION REQUIRED\n...
257
+ cleaned = cleaned.replace(
258
+ /## Memory system — ACTION REQUIRED[\s\S]*?(?=\n\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}|\n\[Subagent)/,
259
+ "",
260
+ ).trim();
261
+
262
+ // You are running a boot check. Follow BOOT.md instructions exactly.\n...
263
+ cleaned = cleaned.replace(
264
+ /^You are running a boot check[\s\S]*?(?=\n\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}|\n\[Subagent)/m,
265
+ "",
266
+ ).trim();
267
+
268
+ // Standalone NO_REPLY / HEARTBEAT_OK that leaked into user messages
269
+ cleaned = cleaned.replace(/^\s*(?:NO_REPLY|HEARTBEAT_OK|HEARTBEAT_CHECK)\s*$/gm, "").trim();
270
+
235
271
  // Old format: ## Retrieved memories from past conversations\n\nCRITICAL INSTRUCTION:...
236
272
  const recallIdx = cleaned.indexOf("## Retrieved memories from past conversations");
237
273
  if (recallIdx !== -1) {
@@ -49,6 +49,13 @@ export async function connectToHub(store: SqliteStore, config: MemosLocalConfig,
49
49
  }) as any;
50
50
  if (result.status === "active" && result.userToken) {
51
51
  log.info(`Pending user approved! Connecting with token. userId=${persisted.userId}`);
52
+ let approvedHubInstanceId = persisted.hubInstanceId || "";
53
+ if (!approvedHubInstanceId) {
54
+ try {
55
+ const info = await hubRequestJson(hubUrl, "", "/api/v1/hub/info", { method: "GET" }) as any;
56
+ approvedHubInstanceId = String(info?.hubInstanceId ?? "");
57
+ } catch { /* best-effort */ }
58
+ }
52
59
  store.setClientHubConnection({
53
60
  hubUrl,
54
61
  userId: persisted.userId,
@@ -58,6 +65,7 @@ export async function connectToHub(store: SqliteStore, config: MemosLocalConfig,
58
65
  connectedAt: Date.now(),
59
66
  identityKey: persisted.identityKey || "",
60
67
  lastKnownStatus: "active",
68
+ hubInstanceId: approvedHubInstanceId,
61
69
  });
62
70
  return store.getClientHubConnection()!;
63
71
  }
@@ -87,7 +95,10 @@ export async function connectToHub(store: SqliteStore, config: MemosLocalConfig,
87
95
  }
88
96
 
89
97
  const hubUrl = normalizeHubUrl(hubAddress);
90
- const me = await hubRequestJson(hubUrl, userToken, "/api/v1/hub/me", { method: "GET" }) as any;
98
+ const [me, info] = await Promise.all([
99
+ hubRequestJson(hubUrl, userToken, "/api/v1/hub/me", { method: "GET" }),
100
+ hubRequestJson(hubUrl, "", "/api/v1/hub/info", { method: "GET" }).catch(() => null),
101
+ ]) as [any, any];
91
102
  const persisted = store.getClientHubConnection();
92
103
  store.setClientHubConnection({
93
104
  hubUrl,
@@ -98,6 +109,7 @@ export async function connectToHub(store: SqliteStore, config: MemosLocalConfig,
98
109
  connectedAt: Date.now(),
99
110
  identityKey: persisted?.identityKey || String(me.identityKey ?? ""),
100
111
  lastKnownStatus: "active",
112
+ hubInstanceId: String(info?.hubInstanceId ?? persisted?.hubInstanceId ?? ""),
101
113
  });
102
114
  return store.getClientHubConnection()!;
103
115
  }
@@ -148,6 +160,7 @@ export async function getHubStatus(store: SqliteStore, config: MemosLocalConfig)
148
160
  connectedAt: Date.now(),
149
161
  identityKey: conn.identityKey || "",
150
162
  lastKnownStatus: "active",
163
+ hubInstanceId: conn.hubInstanceId || "",
151
164
  });
152
165
  const me = await hubRequestJson(normalizeHubUrl(hubAddress), result.userToken, "/api/v1/hub/me", { method: "GET" }) as any;
153
166
  return {
@@ -216,22 +229,28 @@ export async function getHubStatus(store: SqliteStore, config: MemosLocalConfig)
216
229
  body: JSON.stringify({ teamToken, userId: conn.userId }),
217
230
  }) as any;
218
231
  if (regResult.status === "active" && regResult.userToken) {
219
- store.setClientHubConnection({
232
+ const updatedConn = {
220
233
  ...conn,
221
234
  hubUrl: normalizeHubUrl(hubAddress),
222
235
  userToken: regResult.userToken,
223
236
  connectedAt: Date.now(),
224
237
  lastKnownStatus: "active",
225
- });
238
+ };
239
+ store.setClientHubConnection(updatedConn);
226
240
  try {
227
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
+ }
228
247
  return {
229
248
  connected: true,
230
249
  hubUrl: normalizeHubUrl(hubAddress),
231
250
  user: {
232
251
  id: String(me.id),
233
- username: String(me.username ?? ""),
234
- role: String(me.role ?? "member") as UserRole,
252
+ username: latestUsername,
253
+ role: latestRole,
235
254
  status: String(me.status ?? "active"),
236
255
  groups: Array.isArray(me.groups) ? me.groups : [],
237
256
  },
@@ -293,6 +312,12 @@ export async function autoJoinHub(
293
312
  const existingIdentityKey = persisted?.identityKey || "";
294
313
 
295
314
  log.info(`Joining Hub at ${hubUrl} as "${username}"...`);
315
+ let hubInstanceId = "";
316
+ try {
317
+ const info = await hubRequestJson(hubUrl, "", "/api/v1/hub/info", { method: "GET" }) as any;
318
+ hubInstanceId = String(info?.hubInstanceId ?? "");
319
+ } catch { /* best-effort */ }
320
+
296
321
  const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
297
322
  method: "POST",
298
323
  body: JSON.stringify({ teamToken, username, deviceName: hostname, clientIp, identityKey: existingIdentityKey }),
@@ -311,6 +336,7 @@ export async function autoJoinHub(
311
336
  connectedAt: Date.now(),
312
337
  identityKey: returnedIdentityKey,
313
338
  lastKnownStatus: "pending",
339
+ hubInstanceId,
314
340
  });
315
341
  throw new PendingApprovalError(result.userId);
316
342
  }
@@ -337,6 +363,7 @@ export async function autoJoinHub(
337
363
  connectedAt: Date.now(),
338
364
  identityKey: returnedIdentityKey,
339
365
  lastKnownStatus: "active",
366
+ hubInstanceId,
340
367
  });
341
368
  return store.getClientHubConnection()!;
342
369
  }
package/src/client/hub.ts CHANGED
@@ -140,6 +140,7 @@ export async function hubUpdateUsername(
140
140
  newUsername: string,
141
141
  ): Promise<{ ok: boolean; username: string; userToken: string }> {
142
142
  const client = await resolveHubClient(store, ctx);
143
+ const persisted = store.getClientHubConnection();
143
144
  const result = await hubRequestJson(client.hubUrl, client.userToken, "/api/v1/hub/me/update-profile", {
144
145
  method: "POST",
145
146
  body: JSON.stringify({ username: newUsername }),
@@ -152,6 +153,9 @@ export async function hubUpdateUsername(
152
153
  userToken: result.userToken,
153
154
  role: client.role as "admin" | "member",
154
155
  connectedAt: Date.now(),
156
+ identityKey: persisted?.identityKey || "",
157
+ lastKnownStatus: "active",
158
+ hubInstanceId: persisted?.hubInstanceId || "",
155
159
  });
156
160
  }
157
161
  return result;
package/src/hub/server.ts CHANGED
@@ -21,6 +21,7 @@ type HubAuthState = {
21
21
  authSecret: string;
22
22
  bootstrapAdminUserId?: string;
23
23
  bootstrapAdminToken?: string;
24
+ hubInstanceId?: string;
24
25
  };
25
26
 
26
27
  export class HubServer {
@@ -123,6 +124,8 @@ export class HubServer {
123
124
  this.initOnlineTracking();
124
125
  this.offlineCheckTimer = setInterval(() => this.checkOfflineUsers(), HubServer.OFFLINE_CHECK_INTERVAL_MS);
125
126
 
127
+ this.backfillMemoryEmbeddings();
128
+
126
129
  return `http://127.0.0.1:${hubPort}`;
127
130
  }
128
131
 
@@ -168,13 +171,23 @@ export class HubServer {
168
171
  return this.authState.authSecret;
169
172
  }
170
173
 
174
+ get hubInstanceId(): string {
175
+ return this.authState.hubInstanceId ?? "";
176
+ }
177
+
171
178
  private loadAuthState(): HubAuthState {
172
179
  try {
173
180
  const raw = fs.readFileSync(this.authStatePath, "utf8");
174
181
  const parsed = JSON.parse(raw) as HubAuthState;
175
- if (parsed.authSecret) return parsed;
182
+ if (parsed.authSecret) {
183
+ if (!parsed.hubInstanceId) {
184
+ parsed.hubInstanceId = randomUUID();
185
+ fs.writeFileSync(this.authStatePath, JSON.stringify(parsed, null, 2), "utf8");
186
+ }
187
+ return parsed;
188
+ }
176
189
  } catch {}
177
- const initial = { authSecret: randomBytes(32).toString("hex") } as HubAuthState;
190
+ const initial: HubAuthState = { authSecret: randomBytes(32).toString("hex"), hubInstanceId: randomUUID() };
178
191
  fs.mkdirSync(path.dirname(this.authStatePath), { recursive: true });
179
192
  fs.writeFileSync(this.authStatePath, JSON.stringify(initial, null, 2), "utf8");
180
193
  return initial;
@@ -215,10 +228,38 @@ export class HubServer {
215
228
  });
216
229
  }
217
230
 
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
+
218
258
  private embedMemoryAsync(memoryId: string, summary: string, content: string): void {
219
259
  const embedder = this.opts.embedder;
220
260
  if (!embedder) return;
221
- const text = summary || content.slice(0, 500);
261
+ const text = (summary || content || "").slice(0, 500);
262
+ if (!text) return;
222
263
  embedder.embed([text]).then((vectors) => {
223
264
  if (vectors[0]) {
224
265
  this.opts.store.upsertHubMemoryEmbedding(memoryId, new Float32Array(vectors[0]));
@@ -238,6 +279,7 @@ export class HubServer {
238
279
  teamName: this.teamName,
239
280
  version: "0.0.0",
240
281
  apiVersion: "v1",
282
+ hubInstanceId: this.hubInstanceId,
241
283
  });
242
284
  }
243
285
 
@@ -252,59 +294,64 @@ export class HubServer {
252
294
  || (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
253
295
  || req.socket.remoteAddress || "";
254
296
  const identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : "";
297
+ const dryRun = body.dryRun === true;
255
298
 
256
- let existingUser = identityKey
299
+ const identityMatch = identityKey
257
300
  ? this.userManager.findByIdentityKey(identityKey)
258
301
  : null;
259
- if (!existingUser) {
260
- const existingUsers = this.opts.store.listHubUsers();
261
- existingUser = existingUsers.find(u => u.username === username && u.status !== "left" && u.status !== "removed") ?? null;
262
- }
263
302
 
264
- if (existingUser) {
265
- 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
+ }
266
307
 
267
- if (existingUser.status === "active") {
308
+ if (identityMatch.status === "active") {
309
+ if (dryRun) return this.json(res, 200, { status: "active", dryRun: true });
268
310
  const token = issueUserToken(
269
- { userId: existingUser.id, username: existingUser.username, role: existingUser.role, status: "active" },
311
+ { userId: identityMatch.id, username: identityMatch.username, role: identityMatch.role, status: "active" },
270
312
  this.authSecret,
271
313
  );
272
- this.userManager.approveUser(existingUser.id, token);
273
- if (identityKey && !existingUser.identityKey) {
274
- this.opts.store.upsertHubUser({ ...existingUser, identityKey });
275
- }
276
- 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 });
277
316
  }
278
- if (existingUser.status === "pending") {
279
- this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
280
- 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 });
281
321
  }
282
- if (existingUser.status === "rejected") {
322
+ if (identityMatch.status === "rejected") {
323
+ if (dryRun) return this.json(res, 200, { status: "rejected", dryRun: true });
283
324
  if (body.reapply === true) {
284
- this.userManager.resetToPending(existingUser.id);
285
- this.notifyAdmins("user_join_request", "user", username, "");
286
- this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
287
- 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 });
288
329
  }
289
- return this.json(res, 200, { status: "rejected", userId: existingUser.id });
290
- }
291
- if (existingUser.status === "removed") {
292
- this.userManager.rejoinUser(existingUser.id);
293
- this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
294
- this.opts.log.info(`Hub: removed user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
295
- 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 });
296
331
  }
297
- if (existingUser.status === "left") {
298
- this.userManager.rejoinUser(existingUser.id);
299
- this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
300
- this.opts.log.info(`Hub: left user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
301
- 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 });
302
338
  }
303
- if (existingUser.status === "blocked") {
304
- 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 });
305
341
  }
306
342
  }
307
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
+
308
355
  const generatedIdentityKey = identityKey || randomUUID();
309
356
  const user = this.userManager.createPendingUser({
310
357
  username,
@@ -382,10 +429,13 @@ export class HubServer {
382
429
  }
383
430
 
384
431
  if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
432
+ this.opts.store.deleteHubMemoriesByUser(auth.userId);
433
+ this.opts.store.deleteHubTasksByUser(auth.userId);
434
+ this.opts.store.deleteHubSkillsByUser(auth.userId);
385
435
  this.userManager.markUserLeft(auth.userId);
386
436
  this.knownOnlineUsers.delete(auth.userId);
387
437
  this.notifyAdmins("user_left", "user", auth.username, auth.userId);
388
- this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily, status set to "left"`);
438
+ this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily, resources cleaned, status set to "left"`);
389
439
  return this.json(res, 200, { ok: true });
390
440
  }
391
441
 
@@ -530,6 +580,12 @@ export class HubServer {
530
580
  this.authState.bootstrapAdminToken = newToken;
531
581
  this.saveAuthState();
532
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 */ }
533
589
  this.opts.log.info(`Hub: admin "${auth.userId}" renamed user "${userId}" to "${newUsername}"`);
534
590
  return this.json(res, 200, { ok: true, username: newUsername });
535
591
  }
@@ -611,9 +667,7 @@ export class HubServer {
611
667
  createdAt: existing?.createdAt ?? now,
612
668
  updatedAt: now,
613
669
  });
614
- if (this.opts.embedder) {
615
- this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
616
- }
670
+ this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
617
671
  if (!existing) {
618
672
  this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
619
673
  }
@@ -660,24 +714,30 @@ export class HubServer {
660
714
  // Track which IDs are memories vs chunks
661
715
  const memoryIdSet = new Set(memFtsHits.map(({ hit }) => hit.id));
662
716
 
663
- // Attempt vector search and RRF merge if embedder is available
717
+ // Two-stage retrieval: FTS candidates first, then embed + cosine rerank
664
718
  let mergedIds: string[];
665
719
  if (this.opts.embedder) {
666
720
  try {
667
721
  const [queryVec] = await this.opts.embedder.embed([query]);
668
722
  if (queryVec) {
669
723
  const allEmb = this.opts.store.getVisibleHubEmbeddings(auth.userId);
670
- const memEmb = this.opts.store.getVisibleHubMemoryEmbeddings(auth.userId);
671
724
  const scored: Array<{ id: string; score: number }> = [];
672
- const cosineSim = (vec: Float32Array) => {
725
+ const cosineSim = (a: Float32Array | number[], b: number[]) => {
673
726
  let dot = 0, nA = 0, nB = 0;
674
- for (let i = 0; i < queryVec.length && i < vec.length; i++) {
675
- dot += queryVec[i] * vec[i]; nA += queryVec[i] * queryVec[i]; nB += vec[i] * vec[i];
727
+ const len = Math.min(a.length, b.length);
728
+ for (let i = 0; i < len; i++) {
729
+ dot += a[i] * b[i]; nA += a[i] * a[i]; nB += b[i] * b[i];
676
730
  }
677
731
  return nA > 0 && nB > 0 ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
678
732
  };
679
- for (const e of allEmb) scored.push({ id: e.chunkId, score: cosineSim(e.vector) });
680
- for (const e of memEmb) { scored.push({ id: e.memoryId, score: cosineSim(e.vector) }); memoryIdSet.add(e.memoryId); }
733
+ for (const e of allEmb) scored.push({ id: e.chunkId, score: cosineSim(e.vector, queryVec) });
734
+
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);
739
+ }
740
+
681
741
  scored.sort((a, b) => b.score - a.score);
682
742
  const topScored = scored.slice(0, maxResults * 2);
683
743