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

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 (59) 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 +17 -4
  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 +160 -5
  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/shared/llm-call.d.ts +1 -0
  19. package/dist/shared/llm-call.d.ts.map +1 -1
  20. package/dist/shared/llm-call.js +82 -8
  21. package/dist/shared/llm-call.js.map +1 -1
  22. package/dist/skill/evolver.d.ts +2 -0
  23. package/dist/skill/evolver.d.ts.map +1 -1
  24. package/dist/skill/evolver.js +3 -0
  25. package/dist/skill/evolver.js.map +1 -1
  26. package/dist/storage/sqlite.d.ts +4 -1
  27. package/dist/storage/sqlite.d.ts.map +1 -1
  28. package/dist/storage/sqlite.js +8 -4
  29. package/dist/storage/sqlite.js.map +1 -1
  30. package/dist/telemetry.d.ts +12 -5
  31. package/dist/telemetry.d.ts.map +1 -1
  32. package/dist/telemetry.js +135 -38
  33. package/dist/telemetry.js.map +1 -1
  34. package/dist/types.d.ts +1 -2
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/types.js.map +1 -1
  37. package/dist/viewer/html.d.ts.map +1 -1
  38. package/dist/viewer/html.js +473 -191
  39. package/dist/viewer/html.js.map +1 -1
  40. package/dist/viewer/server.d.ts +14 -0
  41. package/dist/viewer/server.d.ts.map +1 -1
  42. package/dist/viewer/server.js +233 -20
  43. package/dist/viewer/server.js.map +1 -1
  44. package/index.ts +26 -2
  45. package/openclaw.plugin.json +1 -1
  46. package/package.json +1 -2
  47. package/scripts/postinstall.cjs +1 -1
  48. package/src/capture/index.ts +8 -0
  49. package/src/client/connector.ts +17 -4
  50. package/src/config.ts +0 -2
  51. package/src/hub/server.ts +157 -5
  52. package/src/ingest/providers/index.ts +41 -7
  53. package/src/shared/llm-call.ts +97 -9
  54. package/src/skill/evolver.ts +5 -0
  55. package/src/storage/sqlite.ts +11 -6
  56. package/src/telemetry.ts +152 -39
  57. package/src/types.ts +1 -2
  58. package/src/viewer/html.ts +473 -191
  59. package/src/viewer/server.ts +208 -20
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.8",
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;
@@ -65,7 +65,7 @@ export async function getHubStatus(store: SqliteStore, config: MemosLocalConfig)
65
65
  try {
66
66
  const result = await hubRequestJson(normalizeHubUrl(hubAddress), "", "/api/v1/hub/join", {
67
67
  method: "POST",
68
- body: JSON.stringify({ teamToken, username: conn.username || "user" }),
68
+ body: JSON.stringify({ teamToken, username: config.sharing?.client?.nickname || conn.username || "user" }),
69
69
  }) as any;
70
70
  if (result.status === "pending") {
71
71
  return {
@@ -123,13 +123,25 @@ export async function getHubStatus(store: SqliteStore, config: MemosLocalConfig)
123
123
 
124
124
  try {
125
125
  const me = await hubRequestJson(normalizeHubUrl(hubAddress), userToken, "/api/v1/hub/me", { method: "GET" }) as any;
126
+ const latestUsername = String(me.username ?? "");
127
+ const latestRole = String(me.role ?? "member") as UserRole;
128
+ if (conn && (conn.username !== latestUsername || conn.role !== latestRole)) {
129
+ store.setClientHubConnection({
130
+ hubUrl: conn.hubUrl,
131
+ userId: conn.userId,
132
+ username: latestUsername,
133
+ userToken: conn.userToken,
134
+ role: latestRole,
135
+ connectedAt: conn.connectedAt,
136
+ });
137
+ }
126
138
  return {
127
139
  connected: true,
128
140
  hubUrl: normalizeHubUrl(hubAddress),
129
141
  user: {
130
142
  id: String(me.id),
131
- username: String(me.username ?? ""),
132
- role: String(me.role ?? "member") as UserRole,
143
+ username: latestUsername,
144
+ role: latestRole,
133
145
  status: String(me.status ?? "active"),
134
146
  },
135
147
  };
@@ -151,7 +163,8 @@ export async function autoJoinHub(
151
163
  const hubUrl = normalizeHubUrl(hubAddress);
152
164
  const osModule = typeof globalThis.process !== "undefined" ? await import("os") : null;
153
165
  const hostname = osModule ? osModule.hostname() : "unknown";
154
- const username = osModule ? osModule.userInfo().username : "user";
166
+ const nickname = config.sharing?.client?.nickname;
167
+ const username = nickname || (osModule ? osModule.userInfo().username : "user");
155
168
  let clientIp = "";
156
169
  if (osModule) {
157
170
  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;
@@ -223,6 +232,7 @@ export class HubServer {
223
232
  });
224
233
  try { this.opts.store.updateHubUserActivity(user.id, joinIp); } catch { /* best-effort */ }
225
234
  this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
235
+ this.notifyAdmins("user_join_request", "user", username, "");
226
236
  return this.json(res, 200, { status: "pending", userId: user.id });
227
237
  }
228
238
 
@@ -260,6 +270,20 @@ export class HubServer {
260
270
  return this.json(res, 429, { error: "rate_limit_exceeded", retryAfterMs: HubServer.RATE_WINDOW_MS });
261
271
  }
262
272
 
273
+ if (req.method === "POST" && routePath === "/api/v1/hub/heartbeat") {
274
+ return this.json(res, 200, { ok: true });
275
+ }
276
+
277
+ if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
278
+ try {
279
+ this.opts.store.updateHubUserActivity(auth.userId, "", 0);
280
+ } catch { /* best-effort */ }
281
+ this.knownOnlineUsers.delete(auth.userId);
282
+ this.notifyAdmins("user_offline", "user", auth.username, auth.userId);
283
+ this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily`);
284
+ return this.json(res, 200, { ok: true });
285
+ }
286
+
263
287
  if (req.method === "GET" && routePath === "/api/v1/hub/me") {
264
288
  const user = this.opts.store.getHubUser(auth.userId);
265
289
  if (!user) return this.json(res, 401, { error: "unauthorized" });
@@ -300,6 +324,7 @@ export class HubServer {
300
324
  const token = issueUserToken({ userId: String(body.userId), username: String(body.username || ""), role: "member", status: "active" }, this.authSecret);
301
325
  const approved = this.userManager.approveUser(String(body.userId), token);
302
326
  if (!approved) return this.json(res, 404, { error: "not_found" });
327
+ try { this.opts.store.updateHubUserActivity(String(body.userId), ""); } catch { /* best-effort */ }
303
328
  return this.json(res, 200, { status: "active", token });
304
329
  }
305
330
 
@@ -315,12 +340,16 @@ export class HubServer {
315
340
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
316
341
  const users = this.opts.store.listHubUsers().filter(u => u.status === "active");
317
342
  const contribs = this.opts.store.getHubUserContributions();
343
+ const ownerId = this.authState.bootstrapAdminUserId || "";
344
+ const now = Date.now();
318
345
  return this.json(res, 200, { users: users.map(u => {
319
346
  const c = contribs[u.id] || { memoryCount: 0, taskCount: 0, skillCount: 0 };
347
+ const isOnline = u.id === ownerId || (!!u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS);
320
348
  return {
321
349
  id: u.id, username: u.username, role: u.role, status: u.status,
322
350
  deviceName: u.deviceName, createdAt: u.createdAt, approvedAt: u.approvedAt,
323
351
  lastIp: u.lastIp || "", lastActiveAt: u.lastActiveAt,
352
+ isOwner: u.id === ownerId, isOnline,
324
353
  memoryCount: c.memoryCount, taskCount: c.taskCount, skillCount: c.skillCount,
325
354
  };
326
355
  }) });
@@ -332,6 +361,9 @@ export class HubServer {
332
361
  const userId = String(body?.userId || "");
333
362
  const newRole = String(body?.role || "");
334
363
  if (!userId || (newRole !== "admin" && newRole !== "member")) return this.json(res, 400, { error: "invalid_params" });
364
+ if (newRole === "member" && userId === this.authState.bootstrapAdminUserId) {
365
+ return this.json(res, 403, { error: "cannot_demote_owner", message: "The hub owner cannot be demoted" });
366
+ }
335
367
  const user = this.opts.store.getHubUser(userId);
336
368
  if (!user || user.status !== "active") return this.json(res, 404, { error: "not_found" });
337
369
  const updatedUser = { ...user, role: newRole as "admin" | "member" };
@@ -353,8 +385,16 @@ export class HubServer {
353
385
  }
354
386
  const user = this.opts.store.getHubUser(userId);
355
387
  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);
388
+ const ttlMs = user.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
389
+ const newToken = issueUserToken(
390
+ { userId: user.id, username: newUsername, role: user.role, status: user.status },
391
+ this.authSecret,
392
+ ttlMs,
393
+ );
394
+ this.userManager.approveUser(user.id, newToken);
395
+ const updated = this.opts.store.getHubUser(userId)!;
396
+ const finalUser = { ...updated, username: newUsername };
397
+ this.opts.store.upsertHubUser(finalUser);
358
398
  this.opts.log.info(`Hub: admin "${auth.userId}" renamed user "${userId}" to "${newUsername}"`);
359
399
  return this.json(res, 200, { ok: true, username: newUsername });
360
400
  }
@@ -365,6 +405,7 @@ export class HubServer {
365
405
  const userId = String(body?.userId || "");
366
406
  if (!userId) return this.json(res, 400, { error: "missing_user_id" });
367
407
  if (userId === auth.userId) return this.json(res, 400, { error: "cannot_remove_self" });
408
+ if (userId === this.authState.bootstrapAdminUserId) return this.json(res, 403, { error: "cannot_remove_owner", message: "The hub owner cannot be removed" });
368
409
  const cleanResources = body?.cleanResources === true;
369
410
  const deleted = this.opts.store.deleteHubUser(userId, cleanResources);
370
411
  if (!deleted) return this.json(res, 404, { error: "not_found" });
@@ -376,6 +417,7 @@ export class HubServer {
376
417
  const body = await this.readJson(req);
377
418
  if (!body?.task) return this.json(res, 400, { error: "invalid_payload" });
378
419
  const task = { ...body.task, sourceUserId: auth.userId };
420
+ const existingTask = task.sourceTaskId ? this.opts.store.getHubTaskBySource(auth.userId, task.sourceTaskId) : null;
379
421
  this.opts.store.upsertHubTask(task);
380
422
  const chunks = Array.isArray(body.chunks) ? body.chunks : [];
381
423
  const chunkIds: string[] = [];
@@ -383,16 +425,23 @@ export class HubServer {
383
425
  this.opts.store.upsertHubChunk({ ...chunk, sourceUserId: auth.userId });
384
426
  chunkIds.push(chunk.id);
385
427
  }
386
- // Async embedding: don't block the response
387
428
  if (this.opts.embedder && chunkIds.length > 0) {
388
429
  this.embedChunksAsync(chunkIds, chunks);
389
430
  }
431
+ if (!existingTask) {
432
+ this.notifyAdmins("resource_shared", "task", String(task.title || task.sourceTaskId || ""), auth.userId);
433
+ }
390
434
  return this.json(res, 200, { ok: true, chunks: chunkIds.length });
391
435
  }
392
436
 
393
437
  if (req.method === "POST" && routePath === "/api/v1/hub/tasks/unshare") {
394
438
  const body = await this.readJson(req);
395
- this.opts.store.deleteHubTaskBySource(auth.userId, String(body.sourceTaskId));
439
+ const srcTaskId = String(body.sourceTaskId);
440
+ const existing = this.opts.store.getHubTaskBySource(auth.userId, srcTaskId);
441
+ this.opts.store.deleteHubTaskBySource(auth.userId, srcTaskId);
442
+ if (existing) {
443
+ this.notifyAdmins("resource_unshared", "task", existing.title || srcTaskId, auth.userId);
444
+ }
396
445
  return this.json(res, 200, { ok: true });
397
446
  }
398
447
 
@@ -423,6 +472,9 @@ export class HubServer {
423
472
  if (this.opts.embedder) {
424
473
  this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
425
474
  }
475
+ if (!existing) {
476
+ this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
477
+ }
426
478
  return this.json(res, 200, { ok: true, memoryId, visibility });
427
479
  }
428
480
 
@@ -430,7 +482,11 @@ export class HubServer {
430
482
  const body = await this.readJson(req);
431
483
  const sourceChunkId = String(body?.sourceChunkId || "");
432
484
  if (!sourceChunkId) return this.json(res, 400, { error: "missing_source_chunk_id" });
485
+ const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
433
486
  this.opts.store.deleteHubMemoryBySource(auth.userId, sourceChunkId);
487
+ if (existing) {
488
+ this.notifyAdmins("resource_unshared", "memory", existing.summary || existing.content?.slice(0, 60) || sourceChunkId, auth.userId);
489
+ }
434
490
  return this.json(res, 200, { ok: true });
435
491
  }
436
492
 
@@ -580,6 +636,9 @@ export class HubServer {
580
636
  createdAt: existing?.createdAt ?? Date.now(),
581
637
  updatedAt: Date.now(),
582
638
  });
639
+ if (!existing) {
640
+ this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
641
+ }
583
642
  return this.json(res, 200, { ok: true, skillId, visibility });
584
643
  }
585
644
 
@@ -602,7 +661,12 @@ export class HubServer {
602
661
 
603
662
  if (req.method === "POST" && routePath === "/api/v1/hub/skills/unpublish") {
604
663
  const body = await this.readJson(req);
605
- this.opts.store.deleteHubSkillBySource(auth.userId, String(body?.sourceSkillId || ""));
664
+ const srcSkillId = String(body?.sourceSkillId || "");
665
+ const existing = this.opts.store.getHubSkillBySource(auth.userId, srcSkillId);
666
+ this.opts.store.deleteHubSkillBySource(auth.userId, srcSkillId);
667
+ if (existing) {
668
+ this.notifyAdmins("resource_unshared", "skill", existing.name || srcSkillId, auth.userId);
669
+ }
606
670
  return this.json(res, 200, { ok: true });
607
671
  }
608
672
 
@@ -614,6 +678,38 @@ export class HubServer {
614
678
  return this.json(res, 200, { tasks });
615
679
  }
616
680
 
681
+ const hubTaskDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-tasks\/([^/]+)\/detail$/) : null;
682
+ if (hubTaskDetailMatch) {
683
+ const taskId = decodeURIComponent(hubTaskDetailMatch[1]);
684
+ const task = this.opts.store.getHubTaskById(taskId);
685
+ if (!task) return this.json(res, 404, { error: "not_found" });
686
+ const chunks = this.opts.store.listHubChunksByTaskId(taskId);
687
+ return this.json(res, 200, {
688
+ id: task.id, title: task.title, summary: task.summary,
689
+ startedAt: task.createdAt, endedAt: task.updatedAt,
690
+ chunks: chunks.map(c => ({ role: c.role, content: c.content, summary: c.summary, kind: c.kind, createdAt: c.createdAt })),
691
+ });
692
+ }
693
+
694
+ const hubSkillDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-skills\/([^/]+)\/detail$/) : null;
695
+ if (hubSkillDetailMatch) {
696
+ const skillId = decodeURIComponent(hubSkillDetailMatch[1]);
697
+ const skill = this.opts.store.getHubSkillById(skillId);
698
+ if (!skill) return this.json(res, 404, { error: "not_found" });
699
+ let files: Array<{ path: string; type: string; size: number }> = [];
700
+ try {
701
+ const bundle = JSON.parse(skill.bundle || "{}");
702
+ if (Array.isArray(bundle.files)) {
703
+ 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) }));
704
+ }
705
+ } catch { /* ignore parse error */ }
706
+ return this.json(res, 200, {
707
+ skill: { id: skill.id, name: skill.name, description: skill.description, version: skill.version, qualityScore: skill.qualityScore, status: "published" },
708
+ files,
709
+ versions: [],
710
+ });
711
+ }
712
+
617
713
  const adminTaskDeleteMatch = req.method === "DELETE" ? routePath.match(/^\/api\/v1\/hub\/admin\/shared-tasks\/([^/]+)$/) : null;
618
714
  if (adminTaskDeleteMatch) {
619
715
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
@@ -706,6 +802,62 @@ export class HubServer {
706
802
  return this.json(res, 404, { error: "not_found" });
707
803
  }
708
804
 
805
+ private notifyAdmins(type: string, resource: string, title: string, fromUserId: string): void {
806
+ try {
807
+ const admins = this.opts.store.listHubUsers("active").filter(u => u.role === "admin" && u.id !== fromUserId);
808
+ for (const admin of admins) {
809
+ this.opts.store.insertHubNotification({ id: randomUUID(), userId: admin.id, type, resource, title });
810
+ }
811
+ } catch { /* best-effort */ }
812
+ }
813
+
814
+ private initOnlineTracking(): void {
815
+ try {
816
+ const ownerId = this.authState.bootstrapAdminUserId || "";
817
+ const users = this.opts.store.listHubUsers("active");
818
+ const now = Date.now();
819
+ for (const u of users) {
820
+ if (u.id === ownerId) continue;
821
+ if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
822
+ this.knownOnlineUsers.add(u.id);
823
+ }
824
+ }
825
+ } catch { /* best-effort */ }
826
+ }
827
+
828
+ private checkOfflineUsers(): void {
829
+ try {
830
+ const ownerId = this.authState.bootstrapAdminUserId || "";
831
+ const users = this.opts.store.listHubUsers("active");
832
+ const now = Date.now();
833
+ const currentlyOnline = new Set<string>();
834
+ for (const u of users) {
835
+ if (u.id === ownerId) continue;
836
+ if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
837
+ currentlyOnline.add(u.id);
838
+ }
839
+ }
840
+ for (const uid of this.knownOnlineUsers) {
841
+ if (!currentlyOnline.has(uid)) {
842
+ const user = users.find(u => u.id === uid);
843
+ if (user) {
844
+ this.notifyAdmins("user_offline", "user", user.username, uid);
845
+ this.opts.log.info(`Hub: user "${user.username}" (${uid}) went offline`);
846
+ }
847
+ }
848
+ }
849
+ for (const uid of currentlyOnline) {
850
+ if (!this.knownOnlineUsers.has(uid)) {
851
+ const user = users.find(u => u.id === uid);
852
+ if (user) {
853
+ this.notifyAdmins("user_online", "user", user.username, uid);
854
+ }
855
+ }
856
+ }
857
+ this.knownOnlineUsers = currentlyOnline;
858
+ } catch { /* best-effort */ }
859
+ }
860
+
709
861
  private authenticate(req: http.IncomingMessage) {
710
862
  const header = req.headers.authorization;
711
863
  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,