@memtensor/memos-local-openclaw-plugin 1.0.4-beta.7 → 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.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/dist/capture/index.d.ts.map +1 -1
  3. package/dist/capture/index.js +6 -0
  4. package/dist/capture/index.js.map +1 -1
  5. package/dist/client/connector.d.ts.map +1 -1
  6. package/dist/client/connector.js +61 -7
  7. package/dist/client/connector.js.map +1 -1
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +0 -2
  10. package/dist/config.js.map +1 -1
  11. package/dist/hub/server.d.ts +7 -0
  12. package/dist/hub/server.d.ts.map +1 -1
  13. package/dist/hub/server.js +171 -8
  14. package/dist/hub/server.js.map +1 -1
  15. package/dist/ingest/providers/index.d.ts.map +1 -1
  16. package/dist/ingest/providers/index.js +37 -6
  17. package/dist/ingest/providers/index.js.map +1 -1
  18. package/dist/recall/engine.d.ts.map +1 -1
  19. package/dist/recall/engine.js +78 -1
  20. package/dist/recall/engine.js.map +1 -1
  21. package/dist/shared/llm-call.d.ts +1 -0
  22. package/dist/shared/llm-call.d.ts.map +1 -1
  23. package/dist/shared/llm-call.js +82 -8
  24. package/dist/shared/llm-call.js.map +1 -1
  25. package/dist/skill/evolver.d.ts +2 -0
  26. package/dist/skill/evolver.d.ts.map +1 -1
  27. package/dist/skill/evolver.js +3 -0
  28. package/dist/skill/evolver.js.map +1 -1
  29. package/dist/storage/sqlite.d.ts +5 -1
  30. package/dist/storage/sqlite.d.ts.map +1 -1
  31. package/dist/storage/sqlite.js +13 -4
  32. package/dist/storage/sqlite.js.map +1 -1
  33. package/dist/telemetry.d.ts +12 -5
  34. package/dist/telemetry.d.ts.map +1 -1
  35. package/dist/telemetry.js +135 -38
  36. package/dist/telemetry.js.map +1 -1
  37. package/dist/types.d.ts +1 -2
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/types.js.map +1 -1
  40. package/dist/viewer/html.d.ts.map +1 -1
  41. package/dist/viewer/html.js +735 -285
  42. package/dist/viewer/html.js.map +1 -1
  43. package/dist/viewer/server.d.ts +16 -0
  44. package/dist/viewer/server.d.ts.map +1 -1
  45. package/dist/viewer/server.js +349 -21
  46. package/dist/viewer/server.js.map +1 -1
  47. package/index.ts +26 -2
  48. package/openclaw.plugin.json +1 -1
  49. package/package.json +1 -2
  50. package/scripts/postinstall.cjs +1 -1
  51. package/src/capture/index.ts +8 -0
  52. package/src/client/connector.ts +62 -7
  53. package/src/config.ts +0 -2
  54. package/src/hub/server.ts +168 -8
  55. package/src/ingest/providers/index.ts +41 -7
  56. package/src/recall/engine.ts +73 -1
  57. package/src/shared/llm-call.ts +97 -9
  58. package/src/skill/evolver.ts +5 -0
  59. package/src/storage/sqlite.ts +19 -6
  60. package/src/telemetry.ts +152 -39
  61. package/src/types.ts +1 -2
  62. package/src/viewer/html.ts +735 -285
  63. package/src/viewer/server.ts +322 -21
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 { fileURLToPath } from "url";
12
13
  import { buildContext } from "./src/config";
13
14
  import type { HostModelsConfig } from "./src/openclaw-api";
14
15
  import { ensureSqliteBinding } from "./src/storage/ensure-binding";
@@ -82,13 +83,20 @@ const memosLocalPlugin = {
82
83
 
83
84
  register(api: OpenClawPluginApi) {
84
85
  // ─── Ensure better-sqlite3 native module is available ───
85
- const pluginDir = path.dirname(new URL(import.meta.url).pathname);
86
+ const pluginDir = path.dirname(fileURLToPath(import.meta.url));
87
+
88
+ function normalizeFsPath(p: string): string {
89
+ return path.resolve(p).replace(/\\/g, "/").toLowerCase();
90
+ }
91
+
86
92
  let sqliteReady = false;
87
93
 
88
94
  function trySqliteLoad(): boolean {
89
95
  try {
90
96
  const resolved = require.resolve("better-sqlite3", { paths: [pluginDir] });
91
- if (!resolved.startsWith(pluginDir)) {
97
+ const resolvedNorm = normalizeFsPath(resolved);
98
+ const pluginNorm = normalizeFsPath(pluginDir);
99
+ if (!resolvedNorm.startsWith(pluginNorm + "/") && resolvedNorm !== pluginNorm) {
92
100
  api.logger.warn(`memos-local: better-sqlite3 resolved outside plugin dir: ${resolved}`);
93
101
  return false;
94
102
  }
@@ -208,6 +216,7 @@ const memosLocalPlugin = {
208
216
  const workspaceDir = api.resolvePath("~/.openclaw/workspace");
209
217
  const skillCtx = { ...ctx, workspaceDir };
210
218
  const skillEvolver = new SkillEvolver(store, engine, skillCtx);
219
+ skillEvolver.onSkillEvolved = (name, type) => telemetry.trackSkillEvolved(name, type);
211
220
  const skillInstaller = new SkillInstaller(store, skillCtx);
212
221
 
213
222
  let pluginVersion = "0.0.0";
@@ -272,6 +281,18 @@ const memosLocalPlugin = {
272
281
  // Falls back to "main" when no hook has fired yet (single-agent setups).
273
282
  let currentAgentId = "main";
274
283
 
284
+ // ─── Check allowPromptInjection policy ───
285
+ // When allowPromptInjection=false, the prompt mutation fields (such as prependContext) in the hook return value
286
+ // will be stripped by the framework. Skip auto-recall to avoid unnecessary LLM/embedding calls.
287
+ const pluginEntry = (api.config as any)?.plugins?.entries?.[api.id];
288
+ const allowPromptInjection = pluginEntry?.hooks?.allowPromptInjection !== false;
289
+ if (!allowPromptInjection) {
290
+ api.logger.info("memos-local: allowPromptInjection=false, auto-recall disabled");
291
+ }
292
+ else {
293
+ api.logger.info("memos-local: allowPromptInjection=true, auto-recall enabled");
294
+ }
295
+
275
296
  const trackTool = (toolName: string, fn: (...args: any[]) => Promise<any>) =>
276
297
  async (...args: any[]) => {
277
298
  const t0 = performance.now();
@@ -283,6 +304,7 @@ const memosLocalPlugin = {
283
304
  return result;
284
305
  } catch (e) {
285
306
  ok = false;
307
+ telemetry.trackError(toolName, (e as Error)?.name ?? "unknown");
286
308
  throw e;
287
309
  } finally {
288
310
  const dur = performance.now() - t0;
@@ -1097,6 +1119,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1097
1119
  parameters: Type.Object({}),
1098
1120
  execute: trackTool("memory_viewer", async () => {
1099
1121
  ctx.log.debug(`memory_viewer called`);
1122
+ telemetry.trackViewerOpened();
1100
1123
  const url = `http://127.0.0.1:${viewerPort}`;
1101
1124
  return {
1102
1125
  content: [
@@ -1548,6 +1571,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1548
1571
  // ─── Auto-recall: inject relevant memories before agent starts ───
1549
1572
 
1550
1573
  api.on("before_agent_start", async (event: { prompt?: string; messages?: unknown[] }, hookCtx?: { agentId?: string; sessionKey?: string }) => {
1574
+ if (!allowPromptInjection) return {};
1551
1575
  if (!event.prompt || event.prompt.length < 3) return;
1552
1576
 
1553
1577
  const recallAgentId = hookCtx?.agentId ?? "main";
@@ -3,7 +3,7 @@
3
3
  "name": "MemOS Local Memory",
4
4
  "description": "Full-write local conversation memory with hybrid search (RRF + MMR + recency). Provides memory_search, memory_get, task_summary, memory_timeline, memory_viewer for layered retrieval.",
5
5
  "kind": "memory",
6
- "version": "0.1.11",
6
+ "version": "0.1.12",
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.4-beta.7",
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",
@@ -51,7 +51,6 @@
51
51
  "@huggingface/transformers": "^3.8.0",
52
52
  "@sinclair/typebox": "^0.34.48",
53
53
  "better-sqlite3": "^12.6.2",
54
- "posthog-node": "^5.28.0",
55
54
  "puppeteer": "^24.38.0",
56
55
  "semver": "^7.7.4",
57
56
  "uuid": "^10.0.0"
@@ -112,7 +112,7 @@ try {
112
112
  function ensureDependencies() {
113
113
  phase(0, "检测核心依赖 / Check core dependencies");
114
114
 
115
- const coreDeps = ["@sinclair/typebox", "uuid", "posthog-node", "@huggingface/transformers"];
115
+ const coreDeps = ["@sinclair/typebox", "uuid", "@huggingface/transformers"];
116
116
  const missing = [];
117
117
  for (const dep of coreDeps) {
118
118
  try {
@@ -75,6 +75,7 @@ export function captureMessages(
75
75
  if (role === "user") {
76
76
  content = stripInboundMetadata(content);
77
77
  } else {
78
+ content = stripThinkingTags(content);
78
79
  content = stripEvidenceWrappers(content, evidenceTag);
79
80
  }
80
81
  if (!content.trim()) continue;
@@ -163,6 +164,13 @@ export function stripInboundMetadata(text: string): string {
163
164
  return stripEnvelopePrefix(result.join("\n")).trim();
164
165
  }
165
166
 
167
+ /** Strip <think…>…</think> blocks emitted by DeepSeek-style reasoning models. */
168
+ const THINKING_TAG_RE = /<think[\s>][\s\S]*?<\/think>\s*/gi;
169
+
170
+ function stripThinkingTags(text: string): string {
171
+ return text.replace(THINKING_TAG_RE, "");
172
+ }
173
+
166
174
  function extractEnvelopeTimestamp(text: string): number | null {
167
175
  const m = ENVELOPE_EXTRACT_RE.exec(text);
168
176
  if (!m) return null;
@@ -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: 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",
@@ -123,13 +165,25 @@ export async function getHubStatus(store: SqliteStore, config: MemosLocalConfig)
123
165
 
124
166
  try {
125
167
  const me = await hubRequestJson(normalizeHubUrl(hubAddress), userToken, "/api/v1/hub/me", { method: "GET" }) as any;
168
+ const latestUsername = String(me.username ?? "");
169
+ const latestRole = String(me.role ?? "member") as UserRole;
170
+ if (conn && (conn.username !== latestUsername || conn.role !== latestRole)) {
171
+ store.setClientHubConnection({
172
+ hubUrl: conn.hubUrl,
173
+ userId: conn.userId,
174
+ username: latestUsername,
175
+ userToken: conn.userToken,
176
+ role: latestRole,
177
+ connectedAt: conn.connectedAt,
178
+ });
179
+ }
126
180
  return {
127
181
  connected: true,
128
182
  hubUrl: normalizeHubUrl(hubAddress),
129
183
  user: {
130
184
  id: String(me.id),
131
- username: String(me.username ?? ""),
132
- role: String(me.role ?? "member") as UserRole,
185
+ username: latestUsername,
186
+ role: latestRole,
133
187
  status: String(me.status ?? "active"),
134
188
  },
135
189
  };
@@ -151,7 +205,8 @@ export async function autoJoinHub(
151
205
  const hubUrl = normalizeHubUrl(hubAddress);
152
206
  const osModule = typeof globalThis.process !== "undefined" ? await import("os") : null;
153
207
  const hostname = osModule ? osModule.hostname() : "unknown";
154
- const username = osModule ? osModule.userInfo().username : "user";
208
+ const nickname = config.sharing?.client?.nickname;
209
+ const username = nickname || (osModule ? osModule.userInfo().username : "user");
155
210
  let clientIp = "";
156
211
  if (osModule) {
157
212
  const nets = osModule.networkInterfaces();
package/src/config.ts CHANGED
@@ -75,8 +75,6 @@ export function resolveConfig(raw: Partial<MemosLocalConfig> | undefined, stateD
75
75
  },
76
76
  telemetry: {
77
77
  enabled: telemetryEnabled,
78
- posthogApiKey: cfg.telemetry?.posthogApiKey ?? process.env.POSTHOG_API_KEY ?? "",
79
- posthogHost: cfg.telemetry?.posthogHost ?? process.env.POSTHOG_HOST ?? "",
80
78
  },
81
79
  summarizer: (() => {
82
80
  const summarizerConfig = resolveProviderFallback<SummarizerConfig>(
package/src/hub/server.ts CHANGED
@@ -34,6 +34,11 @@ export class HubServer {
34
34
  private static readonly RATE_LIMIT_SEARCH = 30;
35
35
  private rateBuckets = new Map<string, { count: number; windowStart: number }>();
36
36
 
37
+ private static readonly OFFLINE_THRESHOLD_MS = 2 * 60 * 1000;
38
+ private static readonly OFFLINE_CHECK_INTERVAL_MS = 30 * 1000;
39
+ private offlineCheckTimer?: ReturnType<typeof setInterval>;
40
+ private knownOnlineUsers = new Set<string>();
41
+
37
42
  constructor(private opts: HubServerOptions) {
38
43
  this.userManager = new HubUserManager(opts.store, opts.log);
39
44
  this.authStatePath = path.join(opts.dataDir, "hub-auth.json");
@@ -101,10 +106,14 @@ export class HubServer {
101
106
  this.opts.log.info(`memos-local: bootstrap admin token persisted to ${this.authStatePath}`);
102
107
  }
103
108
 
109
+ this.initOnlineTracking();
110
+ this.offlineCheckTimer = setInterval(() => this.checkOfflineUsers(), HubServer.OFFLINE_CHECK_INTERVAL_MS);
111
+
104
112
  return `http://127.0.0.1:${this.port}`;
105
113
  }
106
114
 
107
115
  async stop(): Promise<void> {
116
+ if (this.offlineCheckTimer) { clearInterval(this.offlineCheckTimer); this.offlineCheckTimer = undefined; }
108
117
  if (!this.server) return;
109
118
  const server = this.server;
110
119
  this.server = undefined;
@@ -209,12 +218,17 @@ export class HubServer {
209
218
  return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token });
210
219
  }
211
220
  if (existingUser.status === "pending") {
221
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
212
222
  return this.json(res, 200, { status: "pending", userId: existingUser.id });
213
223
  }
214
224
  if (existingUser.status === "rejected") {
215
- this.userManager.resetToPending(existingUser.id);
216
- this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
217
- 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 });
218
232
  }
219
233
  }
220
234
  const user = this.userManager.createPendingUser({
@@ -223,6 +237,7 @@ export class HubServer {
223
237
  });
224
238
  try { this.opts.store.updateHubUserActivity(user.id, joinIp); } catch { /* best-effort */ }
225
239
  this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
240
+ this.notifyAdmins("user_join_request", "user", username, "");
226
241
  return this.json(res, 200, { status: "pending", userId: user.id });
227
242
  }
228
243
 
@@ -260,6 +275,20 @@ export class HubServer {
260
275
  return this.json(res, 429, { error: "rate_limit_exceeded", retryAfterMs: HubServer.RATE_WINDOW_MS });
261
276
  }
262
277
 
278
+ if (req.method === "POST" && routePath === "/api/v1/hub/heartbeat") {
279
+ return this.json(res, 200, { ok: true });
280
+ }
281
+
282
+ if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
283
+ try {
284
+ this.opts.store.updateHubUserActivity(auth.userId, "", 0);
285
+ } catch { /* best-effort */ }
286
+ this.knownOnlineUsers.delete(auth.userId);
287
+ this.notifyAdmins("user_offline", "user", auth.username, auth.userId);
288
+ this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily`);
289
+ return this.json(res, 200, { ok: true });
290
+ }
291
+
263
292
  if (req.method === "GET" && routePath === "/api/v1/hub/me") {
264
293
  const user = this.opts.store.getHubUser(auth.userId);
265
294
  if (!user) return this.json(res, 401, { error: "unauthorized" });
@@ -300,6 +329,7 @@ export class HubServer {
300
329
  const token = issueUserToken({ userId: String(body.userId), username: String(body.username || ""), role: "member", status: "active" }, this.authSecret);
301
330
  const approved = this.userManager.approveUser(String(body.userId), token);
302
331
  if (!approved) return this.json(res, 404, { error: "not_found" });
332
+ try { this.opts.store.updateHubUserActivity(String(body.userId), ""); } catch { /* best-effort */ }
303
333
  return this.json(res, 200, { status: "active", token });
304
334
  }
305
335
 
@@ -315,12 +345,16 @@ export class HubServer {
315
345
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
316
346
  const users = this.opts.store.listHubUsers().filter(u => u.status === "active");
317
347
  const contribs = this.opts.store.getHubUserContributions();
348
+ const ownerId = this.authState.bootstrapAdminUserId || "";
349
+ const now = Date.now();
318
350
  return this.json(res, 200, { users: users.map(u => {
319
351
  const c = contribs[u.id] || { memoryCount: 0, taskCount: 0, skillCount: 0 };
352
+ const isOnline = u.id === ownerId || (!!u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS);
320
353
  return {
321
354
  id: u.id, username: u.username, role: u.role, status: u.status,
322
355
  deviceName: u.deviceName, createdAt: u.createdAt, approvedAt: u.approvedAt,
323
356
  lastIp: u.lastIp || "", lastActiveAt: u.lastActiveAt,
357
+ isOwner: u.id === ownerId, isOnline,
324
358
  memoryCount: c.memoryCount, taskCount: c.taskCount, skillCount: c.skillCount,
325
359
  };
326
360
  }) });
@@ -332,6 +366,9 @@ export class HubServer {
332
366
  const userId = String(body?.userId || "");
333
367
  const newRole = String(body?.role || "");
334
368
  if (!userId || (newRole !== "admin" && newRole !== "member")) return this.json(res, 400, { error: "invalid_params" });
369
+ if (newRole === "member" && userId === this.authState.bootstrapAdminUserId) {
370
+ return this.json(res, 403, { error: "cannot_demote_owner", message: "The hub owner cannot be demoted" });
371
+ }
335
372
  const user = this.opts.store.getHubUser(userId);
336
373
  if (!user || user.status !== "active") return this.json(res, 404, { error: "not_found" });
337
374
  const updatedUser = { ...user, role: newRole as "admin" | "member" };
@@ -353,8 +390,16 @@ export class HubServer {
353
390
  }
354
391
  const user = this.opts.store.getHubUser(userId);
355
392
  if (!user || user.status !== "active") return this.json(res, 404, { error: "not_found" });
356
- const updated = { ...user, username: newUsername };
357
- this.opts.store.upsertHubUser(updated);
393
+ const ttlMs = user.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
394
+ const newToken = issueUserToken(
395
+ { userId: user.id, username: newUsername, role: user.role, status: user.status },
396
+ this.authSecret,
397
+ ttlMs,
398
+ );
399
+ this.userManager.approveUser(user.id, newToken);
400
+ const updated = this.opts.store.getHubUser(userId)!;
401
+ const finalUser = { ...updated, username: newUsername };
402
+ this.opts.store.upsertHubUser(finalUser);
358
403
  this.opts.log.info(`Hub: admin "${auth.userId}" renamed user "${userId}" to "${newUsername}"`);
359
404
  return this.json(res, 200, { ok: true, username: newUsername });
360
405
  }
@@ -365,6 +410,7 @@ export class HubServer {
365
410
  const userId = String(body?.userId || "");
366
411
  if (!userId) return this.json(res, 400, { error: "missing_user_id" });
367
412
  if (userId === auth.userId) return this.json(res, 400, { error: "cannot_remove_self" });
413
+ if (userId === this.authState.bootstrapAdminUserId) return this.json(res, 403, { error: "cannot_remove_owner", message: "The hub owner cannot be removed" });
368
414
  const cleanResources = body?.cleanResources === true;
369
415
  const deleted = this.opts.store.deleteHubUser(userId, cleanResources);
370
416
  if (!deleted) return this.json(res, 404, { error: "not_found" });
@@ -376,6 +422,7 @@ export class HubServer {
376
422
  const body = await this.readJson(req);
377
423
  if (!body?.task) return this.json(res, 400, { error: "invalid_payload" });
378
424
  const task = { ...body.task, sourceUserId: auth.userId };
425
+ const existingTask = task.sourceTaskId ? this.opts.store.getHubTaskBySource(auth.userId, task.sourceTaskId) : null;
379
426
  this.opts.store.upsertHubTask(task);
380
427
  const chunks = Array.isArray(body.chunks) ? body.chunks : [];
381
428
  const chunkIds: string[] = [];
@@ -383,16 +430,23 @@ export class HubServer {
383
430
  this.opts.store.upsertHubChunk({ ...chunk, sourceUserId: auth.userId });
384
431
  chunkIds.push(chunk.id);
385
432
  }
386
- // Async embedding: don't block the response
387
433
  if (this.opts.embedder && chunkIds.length > 0) {
388
434
  this.embedChunksAsync(chunkIds, chunks);
389
435
  }
436
+ if (!existingTask) {
437
+ this.notifyAdmins("resource_shared", "task", String(task.title || task.sourceTaskId || ""), auth.userId);
438
+ }
390
439
  return this.json(res, 200, { ok: true, chunks: chunkIds.length });
391
440
  }
392
441
 
393
442
  if (req.method === "POST" && routePath === "/api/v1/hub/tasks/unshare") {
394
443
  const body = await this.readJson(req);
395
- this.opts.store.deleteHubTaskBySource(auth.userId, String(body.sourceTaskId));
444
+ const srcTaskId = String(body.sourceTaskId);
445
+ const existing = this.opts.store.getHubTaskBySource(auth.userId, srcTaskId);
446
+ this.opts.store.deleteHubTaskBySource(auth.userId, srcTaskId);
447
+ if (existing) {
448
+ this.notifyAdmins("resource_unshared", "task", existing.title || srcTaskId, auth.userId);
449
+ }
396
450
  return this.json(res, 200, { ok: true });
397
451
  }
398
452
 
@@ -423,6 +477,9 @@ export class HubServer {
423
477
  if (this.opts.embedder) {
424
478
  this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
425
479
  }
480
+ if (!existing) {
481
+ this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
482
+ }
426
483
  return this.json(res, 200, { ok: true, memoryId, visibility });
427
484
  }
428
485
 
@@ -430,7 +487,11 @@ export class HubServer {
430
487
  const body = await this.readJson(req);
431
488
  const sourceChunkId = String(body?.sourceChunkId || "");
432
489
  if (!sourceChunkId) return this.json(res, 400, { error: "missing_source_chunk_id" });
490
+ const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
433
491
  this.opts.store.deleteHubMemoryBySource(auth.userId, sourceChunkId);
492
+ if (existing) {
493
+ this.notifyAdmins("resource_unshared", "memory", existing.summary || existing.content?.slice(0, 60) || sourceChunkId, auth.userId);
494
+ }
434
495
  return this.json(res, 200, { ok: true });
435
496
  }
436
497
 
@@ -580,6 +641,9 @@ export class HubServer {
580
641
  createdAt: existing?.createdAt ?? Date.now(),
581
642
  updatedAt: Date.now(),
582
643
  });
644
+ if (!existing) {
645
+ this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
646
+ }
583
647
  return this.json(res, 200, { ok: true, skillId, visibility });
584
648
  }
585
649
 
@@ -602,7 +666,12 @@ export class HubServer {
602
666
 
603
667
  if (req.method === "POST" && routePath === "/api/v1/hub/skills/unpublish") {
604
668
  const body = await this.readJson(req);
605
- this.opts.store.deleteHubSkillBySource(auth.userId, String(body?.sourceSkillId || ""));
669
+ const srcSkillId = String(body?.sourceSkillId || "");
670
+ const existing = this.opts.store.getHubSkillBySource(auth.userId, srcSkillId);
671
+ this.opts.store.deleteHubSkillBySource(auth.userId, srcSkillId);
672
+ if (existing) {
673
+ this.notifyAdmins("resource_unshared", "skill", existing.name || srcSkillId, auth.userId);
674
+ }
606
675
  return this.json(res, 200, { ok: true });
607
676
  }
608
677
 
@@ -614,6 +683,38 @@ export class HubServer {
614
683
  return this.json(res, 200, { tasks });
615
684
  }
616
685
 
686
+ const hubTaskDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-tasks\/([^/]+)\/detail$/) : null;
687
+ if (hubTaskDetailMatch) {
688
+ const taskId = decodeURIComponent(hubTaskDetailMatch[1]);
689
+ const task = this.opts.store.getHubTaskById(taskId);
690
+ if (!task) return this.json(res, 404, { error: "not_found" });
691
+ const chunks = this.opts.store.listHubChunksByTaskId(taskId);
692
+ return this.json(res, 200, {
693
+ id: task.id, title: task.title, summary: task.summary,
694
+ startedAt: task.createdAt, endedAt: task.updatedAt,
695
+ chunks: chunks.map(c => ({ role: c.role, content: c.content, summary: c.summary, kind: c.kind, createdAt: c.createdAt })),
696
+ });
697
+ }
698
+
699
+ const hubSkillDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-skills\/([^/]+)\/detail$/) : null;
700
+ if (hubSkillDetailMatch) {
701
+ const skillId = decodeURIComponent(hubSkillDetailMatch[1]);
702
+ const skill = this.opts.store.getHubSkillById(skillId);
703
+ if (!skill) return this.json(res, 404, { error: "not_found" });
704
+ let files: Array<{ path: string; type: string; size: number }> = [];
705
+ try {
706
+ const bundle = JSON.parse(skill.bundle || "{}");
707
+ if (Array.isArray(bundle.files)) {
708
+ files = bundle.files.map((f: any) => ({ path: f.path ?? f.name ?? "unknown", type: f.type ?? "file", size: f.size ?? (f.content ? f.content.length : 0) }));
709
+ }
710
+ } catch { /* ignore parse error */ }
711
+ return this.json(res, 200, {
712
+ skill: { id: skill.id, name: skill.name, description: skill.description, version: skill.version, qualityScore: skill.qualityScore, status: "published" },
713
+ files,
714
+ versions: [],
715
+ });
716
+ }
717
+
617
718
  const adminTaskDeleteMatch = req.method === "DELETE" ? routePath.match(/^\/api\/v1\/hub\/admin\/shared-tasks\/([^/]+)$/) : null;
618
719
  if (adminTaskDeleteMatch) {
619
720
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
@@ -706,6 +807,65 @@ export class HubServer {
706
807
  return this.json(res, 404, { error: "not_found" });
707
808
  }
708
809
 
810
+ private notifyAdmins(type: string, resource: string, title: string, fromUserId: string, opts?: { dedup?: boolean; deduoWindowMs?: number }): void {
811
+ try {
812
+ const admins = this.opts.store.listHubUsers("active").filter(u => u.role === "admin" && u.id !== fromUserId);
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
+ }
817
+ this.opts.store.insertHubNotification({ id: randomUUID(), userId: admin.id, type, resource, title });
818
+ }
819
+ } catch { /* best-effort */ }
820
+ }
821
+
822
+ private initOnlineTracking(): void {
823
+ try {
824
+ const ownerId = this.authState.bootstrapAdminUserId || "";
825
+ const users = this.opts.store.listHubUsers("active");
826
+ const now = Date.now();
827
+ for (const u of users) {
828
+ if (u.id === ownerId) continue;
829
+ if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
830
+ this.knownOnlineUsers.add(u.id);
831
+ }
832
+ }
833
+ } catch { /* best-effort */ }
834
+ }
835
+
836
+ private checkOfflineUsers(): void {
837
+ try {
838
+ const ownerId = this.authState.bootstrapAdminUserId || "";
839
+ const users = this.opts.store.listHubUsers("active");
840
+ const now = Date.now();
841
+ const currentlyOnline = new Set<string>();
842
+ for (const u of users) {
843
+ if (u.id === ownerId) continue;
844
+ if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
845
+ currentlyOnline.add(u.id);
846
+ }
847
+ }
848
+ for (const uid of this.knownOnlineUsers) {
849
+ if (!currentlyOnline.has(uid)) {
850
+ const user = users.find(u => u.id === uid);
851
+ if (user) {
852
+ this.notifyAdmins("user_offline", "user", user.username, uid);
853
+ this.opts.log.info(`Hub: user "${user.username}" (${uid}) went offline`);
854
+ }
855
+ }
856
+ }
857
+ for (const uid of currentlyOnline) {
858
+ if (!this.knownOnlineUsers.has(uid)) {
859
+ const user = users.find(u => u.id === uid);
860
+ if (user) {
861
+ this.notifyAdmins("user_online", "user", user.username, uid);
862
+ }
863
+ }
864
+ }
865
+ this.knownOnlineUsers = currentlyOnline;
866
+ } catch { /* best-effort */ }
867
+ }
868
+
709
869
  private authenticate(req: http.IncomingMessage) {
710
870
  const header = req.headers.authorization;
711
871
  if (!header || !header.startsWith("Bearer ")) return null;
@@ -1,6 +1,6 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import type { SummarizerConfig, Logger, OpenClawAPI } from "../../types";
3
+ import type { SummarizerConfig, SummaryProvider, Logger, OpenClawAPI } from "../../types";
4
4
  import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, filterRelevantOpenAI, judgeDedupOpenAI, parseFilterResult, parseDedupResult } from "./openai";
5
5
  import type { FilterResult, DedupResult } from "./openai";
6
6
  export type { FilterResult, DedupResult } from "./openai";
@@ -8,6 +8,40 @@ import { summarizeAnthropic, summarizeTaskAnthropic, generateTaskTitleAnthropic,
8
8
  import { summarizeGemini, summarizeTaskGemini, generateTaskTitleGemini, judgeNewTopicGemini, filterRelevantGemini, judgeDedupGemini } from "./gemini";
9
9
  import { summarizeBedrock, summarizeTaskBedrock, generateTaskTitleBedrock, judgeNewTopicBedrock, filterRelevantBedrock, judgeDedupBedrock } from "./bedrock";
10
10
 
11
+ /**
12
+ * Detect provider type from provider key name or base URL.
13
+ */
14
+ function detectProvider(
15
+ providerKey: string | undefined,
16
+ baseUrl: string,
17
+ ): SummaryProvider {
18
+ const key = providerKey?.toLowerCase() ?? "";
19
+ const url = baseUrl.toLowerCase();
20
+ if (key.includes("anthropic") || url.includes("anthropic")) return "anthropic";
21
+ if (key.includes("gemini") || url.includes("generativelanguage.googleapis.com")) {
22
+ return "gemini";
23
+ }
24
+ if (key.includes("bedrock") || url.includes("bedrock")) return "bedrock";
25
+ return "openai_compatible";
26
+ }
27
+
28
+ /**
29
+ * Return the correct endpoint for a given provider and base URL.
30
+ */
31
+ function normalizeEndpointForProvider(
32
+ provider: SummaryProvider,
33
+ baseUrl: string,
34
+ ): string {
35
+ const stripped = baseUrl.replace(/\/+$/, "");
36
+ if (provider === "anthropic") {
37
+ if (stripped.endsWith("/v1/messages")) return stripped;
38
+ return `${stripped}/v1/messages`;
39
+ }
40
+ if (stripped.endsWith("/chat/completions")) return stripped;
41
+ if (stripped.endsWith("/completions")) return stripped;
42
+ return `${stripped}/chat/completions`;
43
+ }
44
+
11
45
  /**
12
46
  * Build a SummarizerConfig from OpenClaw's native model configuration (openclaw.json).
13
47
  * This serves as the final fallback when both strongCfg and plugin summarizer fail or are absent.
@@ -15,7 +49,8 @@ import { summarizeBedrock, summarizeTaskBedrock, generateTaskTitleBedrock, judge
15
49
  function loadOpenClawFallbackConfig(log: Logger): SummarizerConfig | undefined {
16
50
  try {
17
51
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
18
- const cfgPath = path.join(home, ".openclaw", "openclaw.json");
52
+ const ocHome = process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
53
+ const cfgPath = path.join(ocHome, "openclaw.json");
19
54
  if (!fs.existsSync(cfgPath)) return undefined;
20
55
 
21
56
  const raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
@@ -36,13 +71,12 @@ function loadOpenClawFallbackConfig(log: Logger): SummarizerConfig | undefined {
36
71
  const apiKey: string | undefined = providerCfg.apiKey;
37
72
  if (!baseUrl || !apiKey) return undefined;
38
73
 
39
- const endpoint = baseUrl.endsWith("/chat/completions")
40
- ? baseUrl
41
- : baseUrl.replace(/\/+$/, "") + "/chat/completions";
74
+ const provider = detectProvider(providerKey, baseUrl);
75
+ const endpoint = normalizeEndpointForProvider(provider, baseUrl);
42
76
 
43
- log.debug(`OpenClaw fallback model: ${modelId} via ${baseUrl}`);
77
+ log.debug(`OpenClaw fallback model: ${modelId} via ${baseUrl} (${provider})`);
44
78
  return {
45
- provider: "openai_compatible",
79
+ provider,
46
80
  endpoint,
47
81
  apiKey,
48
82
  model: modelId,