@memtensor/memos-local-openclaw-plugin 1.0.4-beta.11 → 1.0.4-beta.13

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/index.ts CHANGED
@@ -386,27 +386,29 @@ const memosLocalPlugin = {
386
386
  }),
387
387
  }) as { memoryId?: string; visibility?: "public" | "group" };
388
388
 
389
- const now = Date.now();
390
- const existing = store.getHubMemoryBySource(hubClient.userId, chunk.id);
391
- store.upsertHubMemory({
392
- id: response?.memoryId ?? existing?.id ?? `${chunk.id}-hub`,
393
- sourceChunkId: chunk.id,
394
- sourceUserId: hubClient.userId,
395
- role: chunk.role,
396
- content: chunk.content,
397
- summary: chunk.summary ?? "",
398
- kind: chunk.kind,
399
- groupId,
400
- visibility,
401
- createdAt: existing?.createdAt ?? now,
402
- updatedAt: now,
403
- });
389
+ const memoryId = response?.memoryId ?? `${chunk.id}-hub`;
390
+
391
+ // Only persist hub_memories locally in Hub mode where this DB owns the data.
392
+ // Client mode relies on the remote Hub for storage and search.
393
+ if (ctx.config.sharing?.role === "hub") {
394
+ const now = Date.now();
395
+ const existing = store.getHubMemoryBySource(hubClient.userId, chunk.id);
396
+ store.upsertHubMemory({
397
+ id: memoryId,
398
+ sourceChunkId: chunk.id,
399
+ sourceUserId: hubClient.userId,
400
+ role: chunk.role,
401
+ content: chunk.content,
402
+ summary: chunk.summary ?? "",
403
+ kind: chunk.kind,
404
+ groupId,
405
+ visibility,
406
+ createdAt: existing?.createdAt ?? now,
407
+ updatedAt: now,
408
+ });
409
+ }
404
410
 
405
- return {
406
- memoryId: response?.memoryId ?? existing?.id ?? `${chunk.id}-hub`,
407
- visibility,
408
- groupId,
409
- };
411
+ return { memoryId, visibility, groupId };
410
412
  };
411
413
 
412
414
  const unshareMemoryFromHub = async (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memtensor/memos-local-openclaw-plugin",
3
- "version": "1.0.4-beta.11",
3
+ "version": "1.0.4-beta.13",
4
4
  "description": "MemOS Local memory plugin for OpenClaw — full-write, hybrid-recall, progressive retrieval",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -21,6 +21,7 @@ export interface HubStatusInfo {
21
21
  username: string;
22
22
  role: UserRole;
23
23
  status: UserStatus | string;
24
+ groups?: Array<{ id: string; name: string }>;
24
25
  };
25
26
  }
26
27
 
@@ -201,25 +202,61 @@ export async function getHubStatus(store: SqliteStore, config: MemosLocalConfig)
201
202
  username: latestUsername,
202
203
  role: latestRole,
203
204
  status: String(me.status ?? "active"),
205
+ groups: Array.isArray(me.groups) ? me.groups : [],
204
206
  },
205
207
  };
206
208
  } catch (err: any) {
207
209
  const is401 = typeof err?.message === "string" && err.message.includes("(401)");
208
210
  if (is401 && conn) {
209
- store.setClientHubConnection({
210
- ...conn,
211
- userToken: "",
212
- lastKnownStatus: "removed",
213
- });
211
+ const teamToken = config.sharing?.client?.teamToken ?? "";
212
+ if (hubAddress && teamToken) {
213
+ try {
214
+ const regResult = await hubRequestJson(normalizeHubUrl(hubAddress), "", "/api/v1/hub/registration-status", {
215
+ method: "POST",
216
+ body: JSON.stringify({ teamToken, userId: conn.userId }),
217
+ }) as any;
218
+ if (regResult.status === "active" && regResult.userToken) {
219
+ store.setClientHubConnection({
220
+ ...conn,
221
+ hubUrl: normalizeHubUrl(hubAddress),
222
+ userToken: regResult.userToken,
223
+ connectedAt: Date.now(),
224
+ lastKnownStatus: "active",
225
+ });
226
+ try {
227
+ const me = await hubRequestJson(normalizeHubUrl(hubAddress), regResult.userToken, "/api/v1/hub/me", { method: "GET" }) as any;
228
+ return {
229
+ connected: true,
230
+ hubUrl: normalizeHubUrl(hubAddress),
231
+ user: {
232
+ id: String(me.id),
233
+ username: String(me.username ?? ""),
234
+ role: String(me.role ?? "member") as UserRole,
235
+ status: String(me.status ?? "active"),
236
+ groups: Array.isArray(me.groups) ? me.groups : [],
237
+ },
238
+ };
239
+ } catch { /* fall through to token-only return */ }
240
+ return {
241
+ connected: true,
242
+ hubUrl: normalizeHubUrl(hubAddress),
243
+ user: { id: conn.userId, username: conn.username || "", role: conn.role as UserRole || "member", status: "active" },
244
+ };
245
+ }
246
+ const realStatus = regResult.status as string;
247
+ store.setClientHubConnection({ ...conn, userToken: "", lastKnownStatus: realStatus });
248
+ return {
249
+ connected: false,
250
+ hubUrl: normalizeHubUrl(hubAddress),
251
+ user: { id: conn.userId, username: conn.username || "", role: "member", status: realStatus },
252
+ };
253
+ } catch { /* registration-status also failed, fall through */ }
254
+ }
255
+ store.setClientHubConnection({ ...conn, userToken: "", lastKnownStatus: "token_expired" });
214
256
  return {
215
257
  connected: false,
216
258
  hubUrl: normalizeHubUrl(hubAddress),
217
- user: {
218
- id: conn.userId,
219
- username: conn.username || "",
220
- role: "member",
221
- status: "removed",
222
- },
259
+ user: { id: conn.userId, username: conn.username || "", role: "member", status: "token_expired" },
223
260
  };
224
261
  }
225
262
  return { connected: false, user: null };
package/src/hub/server.ts CHANGED
@@ -377,18 +377,33 @@ export class HubServer {
377
377
  if (req.method === "POST" && routePath === "/api/v1/hub/admin/approve-user") {
378
378
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
379
379
  const body = await this.readJson(req);
380
- const token = issueUserToken({ userId: String(body.userId), username: String(body.username || ""), role: "member", status: "active" }, this.authSecret);
381
- const approved = this.userManager.approveUser(String(body.userId), token);
380
+ const userId = String(body.userId);
381
+ const username = String(body.username || "");
382
+ const token = issueUserToken({ userId, username, role: "member", status: "active" }, this.authSecret);
383
+ const approved = this.userManager.approveUser(userId, token);
382
384
  if (!approved) return this.json(res, 404, { error: "not_found" });
383
- try { this.opts.store.updateHubUserActivity(String(body.userId), ""); } catch { /* best-effort */ }
385
+ try { this.opts.store.updateHubUserActivity(userId, ""); } catch { /* best-effort */ }
386
+ try {
387
+ this.opts.store.insertHubNotification({
388
+ id: randomUUID(), userId, type: "membership_approved",
389
+ resource: "user", title: `Your request to join team "${this.teamName}" has been approved. Welcome!`,
390
+ });
391
+ } catch { /* best-effort */ }
384
392
  return this.json(res, 200, { status: "active", token });
385
393
  }
386
394
 
387
395
  if (req.method === "POST" && routePath === "/api/v1/hub/admin/reject-user") {
388
396
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
389
397
  const body = await this.readJson(req);
390
- const rejected = this.userManager.rejectUser(String(body.userId));
398
+ const userId = String(body.userId);
399
+ const rejected = this.userManager.rejectUser(userId);
391
400
  if (!rejected) return this.json(res, 404, { error: "not_found" });
401
+ try {
402
+ this.opts.store.insertHubNotification({
403
+ id: randomUUID(), userId, type: "membership_rejected",
404
+ resource: "user", title: `Your request to join team "${this.teamName}" has been declined.`,
405
+ });
406
+ } catch { /* best-effort */ }
392
407
  return this.json(res, 200, { status: "rejected" });
393
408
  }
394
409
 
@@ -1,6 +1,8 @@
1
+ export type ChunkKind = "paragraph" | "code_block" | "error_stack" | "list" | "command";
2
+
1
3
  export interface RawChunk {
2
4
  content: string;
3
- kind: "paragraph";
5
+ kind: ChunkKind;
4
6
  }
5
7
 
6
8
  const MAX_CHUNK_CHARS = 3000;
@@ -28,21 +30,25 @@ const COMMAND_LINE_RE = /^(?:\$|>|#)\s+.+$/gm;
28
30
  */
29
31
  export function chunkText(text: string): RawChunk[] {
30
32
  let remaining = text;
31
- const slots: Array<{ placeholder: string; content: string }> = [];
33
+ const slots: Array<{ placeholder: string; content: string; kind: ChunkKind }> = [];
32
34
  let counter = 0;
33
35
 
34
- function ph(content: string): string {
36
+ function ph(content: string, kind: ChunkKind = "paragraph"): string {
35
37
  const tag = `\x00SLOT_${counter++}\x00`;
36
- slots.push({ placeholder: tag, content: content.trim() });
38
+ slots.push({ placeholder: tag, content: content.trim(), kind });
37
39
  return tag;
38
40
  }
39
41
 
40
- remaining = remaining.replace(FENCED_CODE_RE, (m) => ph(m));
42
+ remaining = remaining.replace(FENCED_CODE_RE, (m) => ph(m, "code_block"));
41
43
  remaining = extractBraceBlocks(remaining, ph);
42
44
 
43
- const structural: RegExp[] = [ERROR_STACK_RE, LIST_BLOCK_RE, COMMAND_LINE_RE];
44
- for (const re of structural) {
45
- remaining = remaining.replace(re, (m) => ph(m));
45
+ const structuralKinds: Array<[RegExp, ChunkKind]> = [
46
+ [ERROR_STACK_RE, "error_stack"],
47
+ [LIST_BLOCK_RE, "list"],
48
+ [COMMAND_LINE_RE, "command"],
49
+ ];
50
+ for (const [re, kind] of structuralKinds) {
51
+ remaining = remaining.replace(re, (m) => ph(m, kind));
46
52
  }
47
53
 
48
54
  const raw: RawChunk[] = [];
@@ -57,7 +63,7 @@ export function chunkText(text: string): RawChunk[] {
57
63
  for (const part of parts) {
58
64
  const slot = slots.find((s) => s.placeholder === part);
59
65
  if (slot) {
60
- raw.push({ content: slot.content, kind: "paragraph" });
66
+ raw.push({ content: slot.content, kind: slot.kind });
61
67
  } else if (part.trim().length >= MIN_CHUNK_CHARS) {
62
68
  raw.push({ content: part.trim(), kind: "paragraph" });
63
69
  }
@@ -69,7 +75,7 @@ export function chunkText(text: string): RawChunk[] {
69
75
 
70
76
  for (const s of slots) {
71
77
  if (!raw.some((c) => c.content === s.content)) {
72
- raw.push({ content: s.content, kind: "paragraph" });
78
+ raw.push({ content: s.content, kind: s.kind });
73
79
  }
74
80
  }
75
81
 
@@ -85,7 +91,7 @@ export function chunkText(text: string): RawChunk[] {
85
91
  */
86
92
  function extractBraceBlocks(
87
93
  text: string,
88
- ph: (content: string) => string,
94
+ ph: (content: string, kind?: ChunkKind) => string,
89
95
  ): string {
90
96
  const lines = text.split("\n");
91
97
  const result: string[] = [];
@@ -119,7 +125,7 @@ function extractBraceBlocks(
119
125
  if (depth <= 0 || (BLOCK_CLOSE_RE.test(line) && depth <= 0)) {
120
126
  const block = blockLines.join("\n");
121
127
  if (block.trim().length >= MIN_CHUNK_CHARS) {
122
- result.push(ph(block));
128
+ result.push(ph(block, "code_block"));
123
129
  } else {
124
130
  result.push(block);
125
131
  }
@@ -135,7 +141,7 @@ function extractBraceBlocks(
135
141
  if (blockLines.length > 0) {
136
142
  const block = blockLines.join("\n");
137
143
  if (block.trim().length >= MIN_CHUNK_CHARS) {
138
- result.push(ph(block));
144
+ result.push(ph(block, "code_block"));
139
145
  } else {
140
146
  result.push(block);
141
147
  }
@@ -74,11 +74,14 @@ 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)
77
+ // Step 1c: Hub memories search only in Hub mode where local DB owns the
78
+ // hub_memories data and embeddings were generated by the same Embedder.
79
+ // Client mode must use remote API (hubSearchMemories) to avoid cross-model
80
+ // embedding mismatch.
78
81
  let hubMemFtsRanked: Array<{ id: string; score: number }> = [];
79
82
  let hubMemVecRanked: Array<{ id: string; score: number }> = [];
80
83
  let hubMemPatternRanked: Array<{ id: string; score: number }> = [];
81
- if (query && this.ctx.config.sharing?.enabled) {
84
+ if (query && this.ctx.config.sharing?.enabled && this.ctx.config.sharing.role === "hub") {
82
85
  try {
83
86
  const hubFtsHits = this.store.searchHubMemories(query, { maxResults: candidatePool });
84
87
  hubMemFtsRanked = hubFtsHits.map(({ hit }, i) => ({
@@ -807,6 +807,20 @@ export class SqliteStore {
807
807
  CREATE INDEX IF NOT EXISTS idx_hub_users_status ON hub_users(status);
808
808
  CREATE INDEX IF NOT EXISTS idx_hub_users_role ON hub_users(role);
809
809
 
810
+ CREATE TABLE IF NOT EXISTS hub_groups (
811
+ id TEXT PRIMARY KEY,
812
+ name TEXT NOT NULL,
813
+ description TEXT NOT NULL DEFAULT '',
814
+ created_at INTEGER NOT NULL
815
+ );
816
+
817
+ CREATE TABLE IF NOT EXISTS hub_group_members (
818
+ group_id TEXT NOT NULL REFERENCES hub_groups(id) ON DELETE CASCADE,
819
+ user_id TEXT NOT NULL REFERENCES hub_users(id) ON DELETE CASCADE,
820
+ joined_at INTEGER NOT NULL,
821
+ PRIMARY KEY (group_id, user_id)
822
+ );
823
+
810
824
  CREATE TABLE IF NOT EXISTS hub_tasks (
811
825
  id TEXT PRIMARY KEY,
812
826
  source_task_id TEXT NOT NULL,
@@ -1915,14 +1929,20 @@ export class SqliteStore {
1915
1929
  getHubUser(userId: string): HubUserRecord | null {
1916
1930
  const row = this.db.prepare('SELECT * FROM hub_users WHERE id = ?').get(userId) as HubUserRow | undefined;
1917
1931
  if (!row) return null;
1918
- return rowToHubUser(row);
1932
+ const user = rowToHubUser(row);
1933
+ user.groups = this.getGroupsForHubUser(userId);
1934
+ return user;
1919
1935
  }
1920
1936
 
1921
1937
  listHubUsers(status?: UserStatus): HubUserRecord[] {
1922
1938
  const rows = status
1923
1939
  ? this.db.prepare('SELECT * FROM hub_users WHERE status = ? ORDER BY created_at').all(status) as HubUserRow[]
1924
1940
  : this.db.prepare('SELECT * FROM hub_users ORDER BY created_at').all() as HubUserRow[];
1925
- return rows.map(rowToHubUser);
1941
+ return rows.map(r => {
1942
+ const user = rowToHubUser(r);
1943
+ user.groups = this.getGroupsForHubUser(r.id);
1944
+ return user;
1945
+ });
1926
1946
  }
1927
1947
 
1928
1948
  deleteHubUser(userId: string, cleanResources = false): boolean {
@@ -1952,6 +1972,35 @@ export class SqliteStore {
1952
1972
  this.db.prepare('UPDATE hub_users SET last_ip = ?, last_active_at = ? WHERE id = ?').run(ip, timestamp ?? Date.now(), userId);
1953
1973
  }
1954
1974
 
1975
+ // ─── Hub Groups ───
1976
+
1977
+ upsertHubGroup(group: { id: string; name: string; description?: string; createdAt: number }): void {
1978
+ this.db.prepare(`
1979
+ INSERT INTO hub_groups (id, name, description, created_at)
1980
+ VALUES (?, ?, ?, ?)
1981
+ ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description
1982
+ `).run(group.id, group.name, group.description ?? "", group.createdAt);
1983
+ }
1984
+
1985
+ addHubGroupMember(groupId: string, userId: string, joinedAt: number): void {
1986
+ this.db.prepare(`
1987
+ INSERT OR IGNORE INTO hub_group_members (group_id, user_id, joined_at)
1988
+ VALUES (?, ?, ?)
1989
+ `).run(groupId, userId, joinedAt);
1990
+ }
1991
+
1992
+ removeHubGroupMember(groupId: string, userId: string): void {
1993
+ this.db.prepare('DELETE FROM hub_group_members WHERE group_id = ? AND user_id = ?').run(groupId, userId);
1994
+ }
1995
+
1996
+ getGroupsForHubUser(userId: string): Array<{ id: string; name: string; description: string }> {
1997
+ return this.db.prepare(`
1998
+ SELECT g.id, g.name, g.description FROM hub_groups g
1999
+ JOIN hub_group_members m ON m.group_id = g.id
2000
+ WHERE m.user_id = ?
2001
+ `).all(userId) as Array<{ id: string; name: string; description: string }>;
2002
+ }
2003
+
1955
2004
  getHubUserContributions(): Record<string, { memoryCount: number; taskCount: number; skillCount: number }> {
1956
2005
  const result: Record<string, { memoryCount: number; taskCount: number; skillCount: number }> = {};
1957
2006
  const memRows = this.db.prepare('SELECT source_user_id, COUNT(*) as cnt FROM hub_memories GROUP BY source_user_id').all() as Array<{ source_user_id: string; cnt: number }>;
@@ -2080,16 +2129,21 @@ export class SqliteStore {
2080
2129
  const limit = options?.maxResults ?? 10;
2081
2130
  const userId = options?.userId ?? "";
2082
2131
  const rows = this.db.prepare(`
2083
- SELECT hc.id, hc.content, hc.summary, hc.role, hc.created_at, ht.title as task_title, ht.visibility, '' as group_name, hu.username as owner_name,
2132
+ SELECT hc.id, hc.content, hc.summary, hc.role, hc.created_at, ht.title as task_title, ht.visibility,
2133
+ COALESCE(hg.name, '') as group_name, hu.username as owner_name,
2084
2134
  bm25(hub_chunks_fts) as rank
2085
2135
  FROM hub_chunks_fts f
2086
2136
  JOIN hub_chunks hc ON hc.rowid = f.rowid
2087
2137
  JOIN hub_tasks ht ON ht.id = hc.hub_task_id
2088
2138
  LEFT JOIN hub_users hu ON hu.id = ht.source_user_id
2139
+ LEFT JOIN hub_groups hg ON hg.id = ht.group_id
2089
2140
  WHERE hub_chunks_fts MATCH ?
2141
+ AND (ht.visibility = 'public'
2142
+ OR ht.source_user_id = ?
2143
+ OR EXISTS (SELECT 1 FROM hub_group_members gm WHERE gm.group_id = ht.group_id AND gm.user_id = ?))
2090
2144
  ORDER BY rank
2091
2145
  LIMIT ?
2092
- `).all(sanitizeFtsQuery(query), limit) as HubSearchRow[];
2146
+ `).all(sanitizeFtsQuery(query), userId, userId, limit) as HubSearchRow[];
2093
2147
  return rows.map((row, idx) => ({ hit: row, rank: idx + 1 }));
2094
2148
  }
2095
2149
 
@@ -2114,7 +2168,10 @@ export class SqliteStore {
2114
2168
  FROM hub_embeddings he
2115
2169
  JOIN hub_chunks hc ON hc.id = he.chunk_id
2116
2170
  JOIN hub_tasks ht ON ht.id = hc.hub_task_id
2117
- `).all() as Array<{ chunk_id: string; vector: Buffer; dimensions: number }>;
2171
+ WHERE ht.visibility = 'public'
2172
+ OR ht.source_user_id = ?
2173
+ OR EXISTS (SELECT 1 FROM hub_group_members gm WHERE gm.group_id = ht.group_id AND gm.user_id = ?)
2174
+ `).all(userId, userId) as Array<{ chunk_id: string; vector: Buffer; dimensions: number }>;
2118
2175
  return rows.map(r => ({
2119
2176
  chunkId: r.chunk_id,
2120
2177
  vector: new Float32Array(r.vector.buffer, r.vector.byteOffset, r.dimensions),
@@ -2123,14 +2180,19 @@ export class SqliteStore {
2123
2180
 
2124
2181
  getVisibleHubSearchHitByChunkId(chunkId: string, userId: string): HubSearchRow | null {
2125
2182
  const row = this.db.prepare(`
2126
- SELECT hc.id, hc.content, hc.summary, hc.role, hc.created_at, ht.title as task_title, ht.visibility, '' as group_name, hu.username as owner_name,
2183
+ SELECT hc.id, hc.content, hc.summary, hc.role, hc.created_at, ht.title as task_title, ht.visibility,
2184
+ COALESCE(hg.name, '') as group_name, hu.username as owner_name,
2127
2185
  0 as rank
2128
2186
  FROM hub_chunks hc
2129
2187
  JOIN hub_tasks ht ON ht.id = hc.hub_task_id
2130
2188
  LEFT JOIN hub_users hu ON hu.id = ht.source_user_id
2189
+ LEFT JOIN hub_groups hg ON hg.id = ht.group_id
2131
2190
  WHERE hc.id = ?
2191
+ AND (ht.visibility = 'public'
2192
+ OR ht.source_user_id = ?
2193
+ OR EXISTS (SELECT 1 FROM hub_group_members gm WHERE gm.group_id = ht.group_id AND gm.user_id = ?))
2132
2194
  LIMIT 1
2133
- `).get(chunkId) as HubSearchRow | undefined;
2195
+ `).get(chunkId, userId, userId) as HubSearchRow | undefined;
2134
2196
  return row ?? null;
2135
2197
  }
2136
2198