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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) 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 +23 -1
  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 +1 -1
  11. package/dist/hub/server.d.ts.map +1 -1
  12. package/dist/hub/server.js +39 -31
  13. package/dist/hub/server.js.map +1 -1
  14. package/dist/ingest/providers/index.d.ts.map +1 -1
  15. package/dist/ingest/providers/index.js +16 -86
  16. package/dist/ingest/providers/index.js.map +1 -1
  17. package/dist/ingest/providers/openai.d.ts +3 -0
  18. package/dist/ingest/providers/openai.d.ts.map +1 -1
  19. package/dist/ingest/providers/openai.js +34 -19
  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 +28 -19
  23. package/dist/recall/engine.js.map +1 -1
  24. package/dist/storage/sqlite.d.ts +30 -7
  25. package/dist/storage/sqlite.d.ts.map +1 -1
  26. package/dist/storage/sqlite.js +139 -60
  27. package/dist/storage/sqlite.js.map +1 -1
  28. package/dist/telemetry.d.ts +4 -1
  29. package/dist/telemetry.d.ts.map +1 -1
  30. package/dist/telemetry.js +26 -18
  31. package/dist/telemetry.js.map +1 -1
  32. package/dist/tools/memory-get.d.ts.map +1 -1
  33. package/dist/tools/memory-get.js +4 -1
  34. package/dist/tools/memory-get.js.map +1 -1
  35. package/dist/types.d.ts +1 -1
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js.map +1 -1
  38. package/dist/viewer/server.d.ts +24 -0
  39. package/dist/viewer/server.d.ts.map +1 -1
  40. package/dist/viewer/server.js +332 -130
  41. package/dist/viewer/server.js.map +1 -1
  42. package/index.ts +66 -30
  43. package/package.json +1 -1
  44. package/scripts/postinstall.cjs +21 -5
  45. package/src/capture/index.ts +36 -0
  46. package/src/client/connector.ts +22 -1
  47. package/src/client/hub.ts +4 -0
  48. package/src/hub/server.ts +42 -26
  49. package/src/ingest/providers/index.ts +30 -93
  50. package/src/ingest/providers/openai.ts +32 -15
  51. package/src/recall/engine.ts +28 -19
  52. package/src/storage/sqlite.ts +156 -65
  53. package/src/telemetry.ts +25 -18
  54. package/src/tools/memory-get.ts +4 -1
  55. package/src/types.ts +2 -0
  56. package/src/viewer/server.ts +313 -125
  57. package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
  58. package/prebuilds/darwin-x64/better_sqlite3.node +0 -0
  59. package/prebuilds/linux-x64/better_sqlite3.node +0 -0
  60. package/prebuilds/win32-x64/better_sqlite3.node +0 -0
  61. package/telemetry.credentials.json +0 -5
package/index.ts CHANGED
@@ -9,6 +9,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
9
9
  import { Type } from "@sinclair/typebox";
10
10
  import * as fs from "fs";
11
11
  import * as path from "path";
12
+ import { createRequire } from "node:module";
12
13
  import { fileURLToPath } from "url";
13
14
  import { buildContext } from "./src/config";
14
15
  import type { HostModelsConfig } from "./src/openclaw-api";
@@ -83,25 +84,56 @@ const memosLocalPlugin = {
83
84
  configSchema: pluginConfigSchema,
84
85
 
85
86
  register(api: OpenClawPluginApi) {
86
- // ─── Ensure better-sqlite3 native module is available ───
87
- const pluginDir = path.dirname(fileURLToPath(import.meta.url));
87
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
88
+ const localRequire = createRequire(import.meta.url);
89
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
90
+
91
+ function detectPluginDir(startDir: string): string {
92
+ let cur = startDir;
93
+ for (let i = 0; i < 6; i++) {
94
+ const pkg = path.join(cur, "package.json");
95
+ if (fs.existsSync(pkg)) return cur;
96
+ const parent = path.dirname(cur);
97
+ if (parent === cur) break;
98
+ cur = parent;
99
+ }
100
+ return startDir;
101
+ }
102
+
103
+ const pluginDir = detectPluginDir(moduleDir);
88
104
 
89
105
  function normalizeFsPath(p: string): string {
90
- return path.resolve(p).replace(/\\/g, "/").toLowerCase();
106
+ return path.resolve(p).replace(/^\\\\\?\\/, "").toLowerCase();
107
+ }
108
+
109
+ function isPathInside(baseDir: string, targetPath: string): boolean {
110
+ const baseNorm = normalizeFsPath(baseDir);
111
+ const targetNorm = normalizeFsPath(targetPath);
112
+ const rel = path.relative(baseNorm, targetNorm);
113
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
114
+ }
115
+
116
+ function runNpm(args: string[]) {
117
+ const { spawnSync } = localRequire("child_process") as typeof import("node:child_process");
118
+ return spawnSync(npmCmd, args, {
119
+ cwd: pluginDir,
120
+ stdio: "pipe",
121
+ shell: false,
122
+ timeout: 120_000,
123
+ });
91
124
  }
92
125
 
93
126
  let sqliteReady = false;
94
127
 
95
128
  function trySqliteLoad(): boolean {
96
129
  try {
97
- const resolved = require.resolve("better-sqlite3", { paths: [pluginDir] });
98
- const resolvedNorm = normalizeFsPath(resolved);
99
- const pluginNorm = normalizeFsPath(pluginDir);
100
- if (!resolvedNorm.startsWith(pluginNorm + "/") && resolvedNorm !== pluginNorm) {
130
+ const resolved = localRequire.resolve("better-sqlite3", { paths: [pluginDir] });
131
+ const resolvedReal = fs.existsSync(resolved) ? fs.realpathSync.native(resolved) : resolved;
132
+ if (!isPathInside(pluginDir, resolvedReal)) {
101
133
  api.logger.warn(`memos-local: better-sqlite3 resolved outside plugin dir: ${resolved}`);
102
134
  return false;
103
135
  }
104
- require(resolved);
136
+ localRequire(resolvedReal);
105
137
  return true;
106
138
  } catch {
107
139
  return false;
@@ -114,13 +146,7 @@ const memosLocalPlugin = {
114
146
  api.logger.warn(`memos-local: better-sqlite3 not found in ${pluginDir}, attempting auto-rebuild ...`);
115
147
 
116
148
  try {
117
- const { spawnSync } = require("child_process");
118
- const rebuildResult = spawnSync("npm", ["rebuild", "better-sqlite3"], {
119
- cwd: pluginDir,
120
- stdio: "pipe",
121
- shell: true,
122
- timeout: 120_000,
123
- });
149
+ const rebuildResult = runNpm(["rebuild", "better-sqlite3"]);
124
150
 
125
151
  const stdout = rebuildResult.stdout?.toString() || "";
126
152
  const stderr = rebuildResult.stderr?.toString() || "";
@@ -128,9 +154,9 @@ const memosLocalPlugin = {
128
154
  if (stderr) api.logger.warn(`memos-local: rebuild stderr: ${stderr.slice(0, 500)}`);
129
155
 
130
156
  if (rebuildResult.status === 0) {
131
- Object.keys(require.cache)
157
+ Object.keys(localRequire.cache)
132
158
  .filter(k => k.includes("better-sqlite3") || k.includes("better_sqlite3"))
133
- .forEach(k => delete require.cache[k]);
159
+ .forEach(k => delete localRequire.cache[k]);
134
160
  sqliteReady = trySqliteLoad();
135
161
  if (sqliteReady) {
136
162
  api.logger.info("memos-local: better-sqlite3 auto-rebuild succeeded!");
@@ -222,10 +248,10 @@ const memosLocalPlugin = {
222
248
 
223
249
  let pluginVersion = "0.0.0";
224
250
  try {
225
- const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf-8"));
251
+ const pkg = JSON.parse(fs.readFileSync(path.join(pluginDir, "package.json"), "utf-8"));
226
252
  pluginVersion = pkg.version ?? pluginVersion;
227
253
  } catch {}
228
- const telemetry = new Telemetry(ctx.config.telemetry ?? {}, stateDir, pluginVersion, ctx.log);
254
+ const telemetry = new Telemetry(ctx.config.telemetry ?? {}, stateDir, pluginVersion, ctx.log, pluginDir);
229
255
 
230
256
  // Install bundled memory-guide skill so OpenClaw loads it (write from embedded content so it works regardless of deploy layout)
231
257
  const workspaceSkillsDir = path.join(workspaceDir, "skills");
@@ -406,7 +432,8 @@ const memosLocalPlugin = {
406
432
  updatedAt: now,
407
433
  });
408
434
  } else if (ctx.config.sharing?.enabled && hubClient.userId) {
409
- store.upsertTeamSharedChunk(chunk.id, { hubMemoryId: memoryId, visibility, groupId });
435
+ const conn = store.getClientHubConnection();
436
+ store.upsertTeamSharedChunk(chunk.id, { hubMemoryId: memoryId, visibility, groupId, hubInstanceId: conn?.hubInstanceId ?? "" });
410
437
  }
411
438
 
412
439
  return { memoryId, visibility, groupId };
@@ -448,7 +475,7 @@ const memosLocalPlugin = {
448
475
  hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override for group/all search." })),
449
476
  userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override for group/all search." })),
450
477
  }),
451
- execute: trackTool("memory_search", async (_toolCallId: any, params: any) => {
478
+ execute: trackTool("memory_search", async (_toolCallId: any, params: any, context?: any) => {
452
479
  const {
453
480
  query,
454
481
  scope: rawScope,
@@ -474,8 +501,8 @@ const memosLocalPlugin = {
474
501
  }
475
502
  const searchLimit = typeof maxResults === "number" ? Math.max(1, Math.min(20, Math.round(maxResults))) : 10;
476
503
 
477
- const agentId = currentAgentId;
478
- const ownerFilter = [getCurrentOwner(), "public"];
504
+ const agentId = context?.agentId ?? currentAgentId;
505
+ const ownerFilter = [`agent:${agentId}`, "public"];
479
506
  const effectiveMaxResults = searchLimit;
480
507
  ctx.log.debug(`memory_search query="${query}" maxResults=${effectiveMaxResults} minScore=${minScore ?? 0.45} role=${role ?? "all"} owner=agent:${agentId}`);
481
508
  const result = await engine.search({ query, maxResults: effectiveMaxResults, minScore, role, ownerFilter });
@@ -713,14 +740,15 @@ const memosLocalPlugin = {
713
740
  chunkId: Type.String({ description: "The chunkId from a memory_search hit" }),
714
741
  window: Type.Optional(Type.Number({ description: "Context window ±N (default 2)" })),
715
742
  }),
716
- execute: trackTool("memory_timeline", async (_toolCallId: any, params: any) => {
717
- ctx.log.debug(`memory_timeline called (agent=${currentAgentId})`);
743
+ execute: trackTool("memory_timeline", async (_toolCallId: any, params: any, context?: any) => {
744
+ const agentId = context?.agentId ?? currentAgentId;
745
+ ctx.log.debug(`memory_timeline called (agent=${agentId})`);
718
746
  const { chunkId, window: win } = params as {
719
747
  chunkId: string;
720
748
  window?: number;
721
749
  };
722
750
 
723
- const ownerFilter = [`agent:${currentAgentId}`, "public"];
751
+ const ownerFilter = [`agent:${agentId}`, "public"];
724
752
  const anchorChunk = store.getChunkForOwners(chunkId, ownerFilter);
725
753
  if (!anchorChunk) {
726
754
  return {
@@ -778,7 +806,8 @@ const memosLocalPlugin = {
778
806
  const { chunkId, maxChars } = params as { chunkId: string; maxChars?: number };
779
807
  const limit = Math.min(maxChars ?? DEFAULTS.getMaxCharsDefault, DEFAULTS.getMaxCharsMax);
780
808
 
781
- const ownerFilter = [`agent:${currentAgentId}`, "public"];
809
+ const agentId = context?.agentId ?? currentAgentId;
810
+ const ownerFilter = [`agent:${agentId}`, "public"];
782
811
  const chunk = store.getChunkForOwners(chunkId, ownerFilter);
783
812
  if (!chunk) {
784
813
  return {
@@ -952,7 +981,8 @@ const memosLocalPlugin = {
952
981
  }),
953
982
  }) as any;
954
983
 
955
- store.markTaskShared(task.id, hubTaskId, chunks.length, visibility, groupId);
984
+ const conn = store.getClientHubConnection();
985
+ store.markTaskShared(task.id, hubTaskId, chunks.length, visibility, groupId, conn?.hubInstanceId ?? "");
956
986
 
957
987
  return {
958
988
  content: [{ type: "text", text: `Shared task "${task.title}" with ${chunks.length} chunks to the hub.` }],
@@ -1781,7 +1811,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1781
1811
 
1782
1812
  // ─── Auto-recall: inject relevant memories before agent starts ───
1783
1813
 
1784
- api.on("before_agent_start", async (event: { prompt?: string; messages?: unknown[] }, hookCtx?: { agentId?: string; sessionKey?: string }) => {
1814
+ api.on("before_prompt_build", async (event: { prompt?: string; messages?: unknown[] }, hookCtx?: { agentId?: string; sessionKey?: string }) => {
1785
1815
  if (!allowPromptInjection) return {};
1786
1816
  if (!event.prompt || event.prompt.length < 3) return;
1787
1817
 
@@ -2245,6 +2275,10 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
2245
2275
  const shared = store.listLocalSharedTasks();
2246
2276
  if (shared.length === 0) return;
2247
2277
 
2278
+ // Only sync tasks that have a hub_task_id (actively shared to remote)
2279
+ const conn = store.getClientHubConnection();
2280
+ const currentHubInstanceId = conn?.hubInstanceId || "";
2281
+
2248
2282
  let hubClient: { hubUrl: string; userToken: string; userId: string } | undefined;
2249
2283
  try {
2250
2284
  hubClient = await resolveHubClient(store, ctx);
@@ -2254,6 +2288,8 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
2254
2288
  const { v4: uuidv4 } = require("uuid");
2255
2289
 
2256
2290
  for (const entry of shared) {
2291
+ if (!entry.hubTaskId) continue;
2292
+ if (currentHubInstanceId && entry.hubInstanceId && entry.hubInstanceId !== currentHubInstanceId) continue;
2257
2293
  const task = store.getTask(entry.taskId);
2258
2294
  if (!task) continue;
2259
2295
  const chunks = store.getChunksByTask(entry.taskId);
@@ -2291,7 +2327,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
2291
2327
  })),
2292
2328
  }),
2293
2329
  });
2294
- store.markTaskShared(entry.taskId, entry.hubTaskId, chunks.length, entry.visibility, entry.groupId);
2330
+ store.markTaskShared(entry.taskId, entry.hubTaskId, chunks.length, entry.visibility, entry.groupId, currentHubInstanceId);
2295
2331
  } catch (err) {
2296
2332
  ctx.log.warn(`incremental sync failed for task=${entry.taskId}: ${err}`);
2297
2333
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memtensor/memos-local-openclaw-plugin",
3
- "version": "1.0.4",
3
+ "version": "1.0.6-beta.1",
4
4
  "description": "MemOS Local memory plugin for OpenClaw — full-write, hybrid-recall, progressive retrieval",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -23,6 +23,11 @@ function phase(n, title) {
23
23
  }
24
24
 
25
25
  const pluginDir = path.resolve(__dirname, "..");
26
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
27
+
28
+ function normalizePathForMatch(p) {
29
+ return path.resolve(p).replace(/^\\\\\?\\/, "").replace(/\\/g, "/").toLowerCase();
30
+ }
26
31
 
27
32
  console.log(`
28
33
  ${CYAN}${BOLD}┌──────────────────────────────────────────────────┐
@@ -42,7 +47,8 @@ log(`Node: ${process.version} Platform: ${process.platform}-${process.arch}`);
42
47
  * ═══════════════════════════════════════════════════════════ */
43
48
 
44
49
  function cleanStaleArtifacts() {
45
- const isExtensionsDir = pluginDir.includes(path.join(".openclaw", "extensions"));
50
+ const pluginDirNorm = normalizePathForMatch(pluginDir);
51
+ const isExtensionsDir = pluginDirNorm.includes("/.openclaw/extensions/");
46
52
  if (!isExtensionsDir) return;
47
53
 
48
54
  const pkgPath = path.join(pluginDir, "package.json");
@@ -133,10 +139,10 @@ function ensureDependencies() {
133
139
  log("Running: npm install --omit=dev ...");
134
140
 
135
141
  const startMs = Date.now();
136
- const result = spawnSync("npm", ["install", "--omit=dev"], {
142
+ const result = spawnSync(npmCmd, ["install", "--omit=dev"], {
137
143
  cwd: pluginDir,
138
144
  stdio: "pipe",
139
- shell: true,
145
+ shell: false,
140
146
  timeout: 120_000,
141
147
  });
142
148
  const elapsed = ((Date.now() - startMs) / 1000).toFixed(1);
@@ -223,8 +229,8 @@ function cleanupLegacy() {
223
229
  newEntry.source = oldSource
224
230
  .replace(/memos-lite-openclaw-plugin/g, "memos-local-openclaw-plugin")
225
231
  .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");
232
+ .replace(/[\\/]memos-local[\\/]/g, `${path.sep}memos-local-openclaw-plugin${path.sep}`)
233
+ .replace(/[\\/]memos-local$/g, `${path.sep}memos-local-openclaw-plugin`);
228
234
  if (newEntry.source !== oldSource) {
229
235
  log(`Updated source path: ${DIM}${oldSource}${RESET} → ${GREEN}${newEntry.source}${RESET}`);
230
236
  cfgChanged = true;
@@ -384,6 +390,16 @@ if (sqliteBindingsExist()) {
384
390
  warn("better-sqlite3 native bindings not found in plugin dir.");
385
391
  log(`Searched in: ${DIM}${sqliteModulePath}/build/${RESET}`);
386
392
  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
+ });
387
403
 
388
404
  const startMs = Date.now();
389
405
  const result = spawnSync("npm", ["rebuild", "better-sqlite3"], {
@@ -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 {
@@ -293,6 +306,12 @@ export async function autoJoinHub(
293
306
  const existingIdentityKey = persisted?.identityKey || "";
294
307
 
295
308
  log.info(`Joining Hub at ${hubUrl} as "${username}"...`);
309
+ let hubInstanceId = "";
310
+ try {
311
+ const info = await hubRequestJson(hubUrl, "", "/api/v1/hub/info", { method: "GET" }) as any;
312
+ hubInstanceId = String(info?.hubInstanceId ?? "");
313
+ } catch { /* best-effort */ }
314
+
296
315
  const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
297
316
  method: "POST",
298
317
  body: JSON.stringify({ teamToken, username, deviceName: hostname, clientIp, identityKey: existingIdentityKey }),
@@ -311,6 +330,7 @@ export async function autoJoinHub(
311
330
  connectedAt: Date.now(),
312
331
  identityKey: returnedIdentityKey,
313
332
  lastKnownStatus: "pending",
333
+ hubInstanceId,
314
334
  });
315
335
  throw new PendingApprovalError(result.userId);
316
336
  }
@@ -337,6 +357,7 @@ export async function autoJoinHub(
337
357
  connectedAt: Date.now(),
338
358
  identityKey: returnedIdentityKey,
339
359
  lastKnownStatus: "active",
360
+ hubInstanceId,
340
361
  });
341
362
  return store.getClientHubConnection()!;
342
363
  }
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 {
@@ -168,13 +169,23 @@ export class HubServer {
168
169
  return this.authState.authSecret;
169
170
  }
170
171
 
172
+ get hubInstanceId(): string {
173
+ return this.authState.hubInstanceId ?? "";
174
+ }
175
+
171
176
  private loadAuthState(): HubAuthState {
172
177
  try {
173
178
  const raw = fs.readFileSync(this.authStatePath, "utf8");
174
179
  const parsed = JSON.parse(raw) as HubAuthState;
175
- if (parsed.authSecret) return parsed;
180
+ if (parsed.authSecret) {
181
+ if (!parsed.hubInstanceId) {
182
+ parsed.hubInstanceId = randomUUID();
183
+ fs.writeFileSync(this.authStatePath, JSON.stringify(parsed, null, 2), "utf8");
184
+ }
185
+ return parsed;
186
+ }
176
187
  } catch {}
177
- const initial = { authSecret: randomBytes(32).toString("hex") } as HubAuthState;
188
+ const initial: HubAuthState = { authSecret: randomBytes(32).toString("hex"), hubInstanceId: randomUUID() };
178
189
  fs.mkdirSync(path.dirname(this.authStatePath), { recursive: true });
179
190
  fs.writeFileSync(this.authStatePath, JSON.stringify(initial, null, 2), "utf8");
180
191
  return initial;
@@ -215,19 +226,8 @@ export class HubServer {
215
226
  });
216
227
  }
217
228
 
218
- private embedMemoryAsync(memoryId: string, summary: string, content: string): void {
219
- const embedder = this.opts.embedder;
220
- if (!embedder) return;
221
- const text = summary || content.slice(0, 500);
222
- embedder.embed([text]).then((vectors) => {
223
- if (vectors[0]) {
224
- this.opts.store.upsertHubMemoryEmbedding(memoryId, new Float32Array(vectors[0]));
225
- this.opts.log.info(`hub: embedded shared memory ${memoryId}`);
226
- }
227
- }).catch((err) => {
228
- this.opts.log.warn(`hub: embedding shared memory failed: ${err}`);
229
- });
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
231
 
232
232
  private async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
233
233
  const url = new URL(req.url || "/", `http://127.0.0.1:${this.port}`);
@@ -238,6 +238,7 @@ export class HubServer {
238
238
  teamName: this.teamName,
239
239
  version: "0.0.0",
240
240
  apiVersion: "v1",
241
+ hubInstanceId: this.hubInstanceId,
241
242
  });
242
243
  }
243
244
 
@@ -382,10 +383,13 @@ export class HubServer {
382
383
  }
383
384
 
384
385
  if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
386
+ this.opts.store.deleteHubMemoriesByUser(auth.userId);
387
+ this.opts.store.deleteHubTasksByUser(auth.userId);
388
+ this.opts.store.deleteHubSkillsByUser(auth.userId);
385
389
  this.userManager.markUserLeft(auth.userId);
386
390
  this.knownOnlineUsers.delete(auth.userId);
387
391
  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"`);
392
+ this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily, resources cleaned, status set to "left"`);
389
393
  return this.json(res, 200, { ok: true });
390
394
  }
391
395
 
@@ -611,9 +615,7 @@ export class HubServer {
611
615
  createdAt: existing?.createdAt ?? now,
612
616
  updatedAt: now,
613
617
  });
614
- if (this.opts.embedder) {
615
- this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
616
- }
618
+ // No embedding on share — hub memory vectors are computed on-the-fly at search time
617
619
  if (!existing) {
618
620
  this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
619
621
  }
@@ -660,24 +662,38 @@ export class HubServer {
660
662
  // Track which IDs are memories vs chunks
661
663
  const memoryIdSet = new Set(memFtsHits.map(({ hit }) => hit.id));
662
664
 
663
- // Attempt vector search and RRF merge if embedder is available
665
+ // Two-stage retrieval: FTS candidates first, then embed + cosine rerank
664
666
  let mergedIds: string[];
665
667
  if (this.opts.embedder) {
666
668
  try {
667
669
  const [queryVec] = await this.opts.embedder.embed([query]);
668
670
  if (queryVec) {
669
671
  const allEmb = this.opts.store.getVisibleHubEmbeddings(auth.userId);
670
- const memEmb = this.opts.store.getVisibleHubMemoryEmbeddings(auth.userId);
671
672
  const scored: Array<{ id: string; score: number }> = [];
672
- const cosineSim = (vec: Float32Array) => {
673
+ const cosineSim = (a: Float32Array | number[], b: number[]) => {
673
674
  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];
675
+ const len = Math.min(a.length, b.length);
676
+ for (let i = 0; i < len; i++) {
677
+ dot += a[i] * b[i]; nA += a[i] * a[i]; nB += b[i] * b[i];
676
678
  }
677
679
  return nA > 0 && nB > 0 ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
678
680
  };
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); }
681
+ for (const e of allEmb) scored.push({ id: e.chunkId, score: cosineSim(e.vector, queryVec) });
682
+
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 */ }
695
+ }
696
+
681
697
  scored.sort((a, b) => b.score - a.score);
682
698
  const topScored = scored.slice(0, maxResults * 2);
683
699