@memtensor/memos-local-openclaw-plugin 1.0.4-beta.8 → 1.0.4-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memtensor/memos-local-openclaw-plugin",
3
- "version": "1.0.4-beta.8",
3
+ "version": "1.0.4-beta.9",
4
4
  "description": "MemOS Local memory plugin for OpenClaw — full-write, hybrid-recall, progressive retrieval",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -34,6 +34,41 @@ export async function connectToHub(store: SqliteStore, config: MemosLocalConfig,
34
34
 
35
35
  if (!userToken && config.sharing?.client?.teamToken) {
36
36
  if (!log) throw new Error("hub client connection is not configured (no userToken, has teamToken but no logger for auto-join)");
37
+
38
+ // If DB has a pending connection (userId exists, no token), check registration-status first
39
+ const persisted = store.getClientHubConnection();
40
+ if (persisted?.userId && !persisted.userToken && hubAddress) {
41
+ const hubUrl = normalizeHubUrl(hubAddress);
42
+ const teamToken = config.sharing.client!.teamToken!;
43
+ try {
44
+ const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/registration-status", {
45
+ method: "POST",
46
+ body: JSON.stringify({ teamToken, userId: persisted.userId }),
47
+ }) as any;
48
+ if (result.status === "active" && result.userToken) {
49
+ log.info(`Pending user approved! Connecting with token. userId=${persisted.userId}`);
50
+ store.setClientHubConnection({
51
+ hubUrl,
52
+ userId: persisted.userId,
53
+ username: persisted.username || "",
54
+ userToken: result.userToken,
55
+ role: "member",
56
+ connectedAt: Date.now(),
57
+ });
58
+ return store.getClientHubConnection()!;
59
+ }
60
+ if (result.status === "pending") {
61
+ throw new PendingApprovalError(persisted.userId);
62
+ }
63
+ if (result.status === "rejected") {
64
+ throw new Error("Join request was rejected by the Hub admin.");
65
+ }
66
+ } catch (err) {
67
+ if (err instanceof PendingApprovalError) throw err;
68
+ log.warn(`registration-status check failed, falling back to autoJoinHub: ${err}`);
69
+ }
70
+ }
71
+
37
72
  return autoJoinHub(store, config, log);
38
73
  }
39
74
 
@@ -56,16 +91,23 @@ export async function connectToHub(store: SqliteStore, config: MemosLocalConfig,
56
91
 
57
92
  export async function getHubStatus(store: SqliteStore, config: MemosLocalConfig): Promise<HubStatusInfo> {
58
93
  const conn = store.getClientHubConnection();
59
- const hubAddress = conn?.hubUrl || config.sharing?.client?.hubAddress || "";
94
+ const configHubAddress = config.sharing?.client?.hubAddress || "";
95
+ const hubAddress = conn?.hubUrl || (configHubAddress ? normalizeHubUrl(configHubAddress) : "");
60
96
  const userToken = conn?.userToken || config.sharing?.client?.userToken || "";
61
97
 
98
+ // If DB has a connection to a different Hub than config, the DB data is stale
99
+ if (conn && configHubAddress && conn.hubUrl && normalizeHubUrl(configHubAddress) !== conn.hubUrl) {
100
+ store.clearClientHubConnection();
101
+ return { connected: false, user: null };
102
+ }
103
+
62
104
  if (conn && conn.userId && (!userToken || userToken === "")) {
63
105
  const teamToken = config.sharing?.client?.teamToken ?? "";
64
106
  if (hubAddress && teamToken) {
65
107
  try {
66
- const result = await hubRequestJson(normalizeHubUrl(hubAddress), "", "/api/v1/hub/join", {
108
+ const result = await hubRequestJson(normalizeHubUrl(hubAddress), "", "/api/v1/hub/registration-status", {
67
109
  method: "POST",
68
- body: JSON.stringify({ teamToken, username: config.sharing?.client?.nickname || conn.username || "user" }),
110
+ body: JSON.stringify({ teamToken, userId: conn.userId }),
69
111
  }) as any;
70
112
  if (result.status === "pending") {
71
113
  return {
@@ -82,7 +124,7 @@ export async function getHubStatus(store: SqliteStore, config: MemosLocalConfig)
82
124
  if (result.status === "active" && result.userToken) {
83
125
  store.setClientHubConnection({
84
126
  hubUrl: normalizeHubUrl(hubAddress),
85
- userId: String(result.userId),
127
+ userId: conn.userId,
86
128
  username: conn.username || "",
87
129
  userToken: result.userToken,
88
130
  role: "member",
package/src/hub/server.ts CHANGED
@@ -218,12 +218,17 @@ export class HubServer {
218
218
  return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token });
219
219
  }
220
220
  if (existingUser.status === "pending") {
221
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
221
222
  return this.json(res, 200, { status: "pending", userId: existingUser.id });
222
223
  }
223
224
  if (existingUser.status === "rejected") {
224
- this.userManager.resetToPending(existingUser.id);
225
- this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
226
- return this.json(res, 200, { status: "pending", userId: existingUser.id });
225
+ if (body.reapply === true) {
226
+ this.userManager.resetToPending(existingUser.id);
227
+ this.notifyAdmins("user_join_request", "user", username, "");
228
+ this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
229
+ return this.json(res, 200, { status: "pending", userId: existingUser.id });
230
+ }
231
+ return this.json(res, 200, { status: "rejected", userId: existingUser.id });
227
232
  }
228
233
  }
229
234
  const user = this.userManager.createPendingUser({
@@ -802,10 +807,13 @@ export class HubServer {
802
807
  return this.json(res, 404, { error: "not_found" });
803
808
  }
804
809
 
805
- private notifyAdmins(type: string, resource: string, title: string, fromUserId: string): void {
810
+ private notifyAdmins(type: string, resource: string, title: string, fromUserId: string, opts?: { dedup?: boolean; deduoWindowMs?: number }): void {
806
811
  try {
807
812
  const admins = this.opts.store.listHubUsers("active").filter(u => u.role === "admin" && u.id !== fromUserId);
808
813
  for (const admin of admins) {
814
+ if (opts?.dedup && this.opts.store.hasRecentHubNotification(admin.id, type, resource, opts.deduoWindowMs ?? 300_000)) {
815
+ continue;
816
+ }
809
817
  this.opts.store.insertHubNotification({ id: randomUUID(), userId: admin.id, type, resource, title });
810
818
  }
811
819
  } catch { /* best-effort */ }
@@ -74,10 +74,49 @@ export class RecallEngine {
74
74
  score: 1 / (i + 1),
75
75
  }));
76
76
 
77
+ // Step 1c: Hub memories search (when sharing is enabled and hub_memories exist)
78
+ let hubMemFtsRanked: Array<{ id: string; score: number }> = [];
79
+ let hubMemVecRanked: Array<{ id: string; score: number }> = [];
80
+ if (query && this.ctx.config.sharing?.enabled) {
81
+ try {
82
+ const hubFtsHits = this.store.searchHubMemories(query, { maxResults: candidatePool });
83
+ hubMemFtsRanked = hubFtsHits.map(({ hit }, i) => ({
84
+ id: `hubmem:${hit.id}`, score: 1 / (i + 1),
85
+ }));
86
+ } catch { /* hub_memories table may not exist */ }
87
+ try {
88
+ const hubMemEmbs = this.store.getVisibleHubMemoryEmbeddings("");
89
+ if (hubMemEmbs.length > 0) {
90
+ const qv = await this.embedder.embedQuery(query).catch(() => null);
91
+ if (qv) {
92
+ const scored: Array<{ id: string; score: number }> = [];
93
+ for (const e of hubMemEmbs) {
94
+ let dot = 0, nA = 0, nB = 0;
95
+ for (let i = 0; i < qv.length && i < e.vector.length; i++) {
96
+ dot += qv[i] * e.vector[i]; nA += qv[i] * qv[i]; nB += e.vector[i] * e.vector[i];
97
+ }
98
+ const sim = nA > 0 && nB > 0 ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
99
+ if (sim > 0.3) {
100
+ scored.push({ id: `hubmem:${e.memoryId}`, score: sim });
101
+ }
102
+ }
103
+ scored.sort((a, b) => b.score - a.score);
104
+ hubMemVecRanked = scored.slice(0, candidatePool);
105
+ }
106
+ }
107
+ } catch { /* best-effort */ }
108
+ if (hubMemFtsRanked.length > 0 || hubMemVecRanked.length > 0) {
109
+ this.ctx.log.debug(`recall: hub_memories candidates: fts=${hubMemFtsRanked.length}, vec=${hubMemVecRanked.length}`);
110
+ }
111
+ }
112
+
77
113
  // Step 2: RRF fusion
78
114
  const ftsRanked = ftsCandidates.map((c) => ({ id: c.chunkId, score: c.score }));
79
115
  const vecRanked = vecCandidates.map((c) => ({ id: c.chunkId, score: c.score }));
80
- const rrfScores = rrfFuse([ftsRanked, vecRanked, patternRanked], recallCfg.rrfK);
116
+ const allRankedLists = [ftsRanked, vecRanked, patternRanked];
117
+ if (hubMemFtsRanked.length > 0) allRankedLists.push(hubMemFtsRanked);
118
+ if (hubMemVecRanked.length > 0) allRankedLists.push(hubMemVecRanked);
119
+ const rrfScores = rrfFuse(allRankedLists, recallCfg.rrfK);
81
120
 
82
121
  if (rrfScores.size === 0) {
83
122
  this.recordQuery(query, maxResults, minScore, 0);
@@ -101,6 +140,11 @@ export class RecallEngine {
101
140
 
102
141
  // Step 4: Time decay
103
142
  const withTs = mmrResults.map((r) => {
143
+ if (r.id.startsWith("hubmem:")) {
144
+ const memId = r.id.slice(7);
145
+ const mem = this.store.getHubMemoryById(memId);
146
+ return { ...r, createdAt: mem?.createdAt ?? 0 };
147
+ }
104
148
  const chunk = this.store.getChunk(r.id);
105
149
  return { ...r, createdAt: chunk?.createdAt ?? 0 };
106
150
  });
@@ -128,6 +172,34 @@ export class RecallEngine {
128
172
  const hits: SearchHit[] = [];
129
173
  for (const candidate of normalized) {
130
174
  if (hits.length >= maxResults) break;
175
+
176
+ if (candidate.id.startsWith("hubmem:")) {
177
+ const memId = candidate.id.slice(7);
178
+ const mem = this.store.getHubMemoryById(memId);
179
+ if (!mem) continue;
180
+ if (roleFilter && mem.role !== roleFilter) continue;
181
+ hits.push({
182
+ summary: mem.summary || mem.content.slice(0, 200),
183
+ original_excerpt: mem.content,
184
+ ref: {
185
+ sessionKey: `hub-shared:${mem.sourceUserId}`,
186
+ chunkId: mem.id,
187
+ turnId: "",
188
+ seq: 0,
189
+ },
190
+ score: Math.round(candidate.score * 1000) / 1000,
191
+ taskId: null,
192
+ skillId: null,
193
+ owner: `hub-user:${mem.sourceUserId}`,
194
+ source: {
195
+ ts: mem.createdAt,
196
+ role: (mem.role || "assistant") as any,
197
+ sessionKey: `hub-shared:${mem.sourceUserId}`,
198
+ },
199
+ });
200
+ continue;
201
+ }
202
+
131
203
  const chunk = this.store.getChunk(candidate.id);
132
204
  if (!chunk) continue;
133
205
  if (roleFilter && chunk.role !== roleFilter) continue;
@@ -2156,6 +2156,14 @@ export class SqliteStore {
2156
2156
  ).run(n.id, n.userId, n.type, n.resource, n.title, n.message ?? '', Date.now());
2157
2157
  }
2158
2158
 
2159
+ hasRecentHubNotification(userId: string, type: string, resource: string, windowMs: number = 300_000): boolean {
2160
+ const since = Date.now() - windowMs;
2161
+ const row = this.db.prepare(
2162
+ 'SELECT COUNT(*) AS cnt FROM hub_notifications WHERE user_id = ? AND type = ? AND resource = ? AND created_at > ?'
2163
+ ).get(userId, type, resource, since) as { cnt: number };
2164
+ return row.cnt > 0;
2165
+ }
2166
+
2159
2167
  listHubNotifications(userId: string, opts?: { unreadOnly?: boolean; limit?: number }): Array<{ id: string; userId: string; type: string; resource: string; title: string; message: string; read: boolean; createdAt: number }> {
2160
2168
  const where = opts?.unreadOnly ? 'WHERE user_id = ? AND read = 0' : 'WHERE user_id = ?';
2161
2169
  const limit = opts?.limit ?? 50;