@memtensor/memos-local-openclaw-plugin 1.0.4-beta.6 → 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 (143) hide show
  1. package/README.md +38 -21
  2. package/dist/capture/index.d.ts +1 -1
  3. package/dist/capture/index.d.ts.map +1 -1
  4. package/dist/capture/index.js +29 -3
  5. package/dist/capture/index.js.map +1 -1
  6. package/dist/client/connector.d.ts +29 -0
  7. package/dist/client/connector.d.ts.map +1 -0
  8. package/dist/client/connector.js +231 -0
  9. package/dist/client/connector.js.map +1 -0
  10. package/dist/client/hub.d.ts +61 -0
  11. package/dist/client/hub.d.ts.map +1 -0
  12. package/dist/client/hub.js +170 -0
  13. package/dist/client/hub.js.map +1 -0
  14. package/dist/client/skill-sync.d.ts +36 -0
  15. package/dist/client/skill-sync.d.ts.map +1 -0
  16. package/dist/client/skill-sync.js +226 -0
  17. package/dist/client/skill-sync.js.map +1 -0
  18. package/dist/config.d.ts +2 -1
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +70 -3
  21. package/dist/config.js.map +1 -1
  22. package/dist/embedding/index.d.ts +4 -2
  23. package/dist/embedding/index.d.ts.map +1 -1
  24. package/dist/embedding/index.js +17 -1
  25. package/dist/embedding/index.js.map +1 -1
  26. package/dist/hub/auth.d.ts +19 -0
  27. package/dist/hub/auth.d.ts.map +1 -0
  28. package/dist/hub/auth.js +70 -0
  29. package/dist/hub/auth.js.map +1 -0
  30. package/dist/hub/server.d.ts +48 -0
  31. package/dist/hub/server.d.ts.map +1 -0
  32. package/dist/hub/server.js +922 -0
  33. package/dist/hub/server.js.map +1 -0
  34. package/dist/hub/user-manager.d.ts +31 -0
  35. package/dist/hub/user-manager.d.ts.map +1 -0
  36. package/dist/hub/user-manager.js +129 -0
  37. package/dist/hub/user-manager.js.map +1 -0
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +8 -4
  41. package/dist/index.js.map +1 -1
  42. package/dist/ingest/providers/index.d.ts +10 -2
  43. package/dist/ingest/providers/index.d.ts.map +1 -1
  44. package/dist/ingest/providers/index.js +203 -6
  45. package/dist/ingest/providers/index.js.map +1 -1
  46. package/dist/ingest/providers/openai.d.ts +1 -0
  47. package/dist/ingest/providers/openai.d.ts.map +1 -1
  48. package/dist/ingest/providers/openai.js +1 -0
  49. package/dist/ingest/providers/openai.js.map +1 -1
  50. package/dist/ingest/task-processor.js +1 -1
  51. package/dist/ingest/task-processor.js.map +1 -1
  52. package/dist/openclaw-api.d.ts +53 -0
  53. package/dist/openclaw-api.d.ts.map +1 -0
  54. package/dist/openclaw-api.js +189 -0
  55. package/dist/openclaw-api.js.map +1 -0
  56. package/dist/recall/engine.js +1 -1
  57. package/dist/recall/engine.js.map +1 -1
  58. package/dist/shared/llm-call.d.ts +4 -1
  59. package/dist/shared/llm-call.d.ts.map +1 -1
  60. package/dist/shared/llm-call.js +14 -1
  61. package/dist/shared/llm-call.js.map +1 -1
  62. package/dist/sharing/types.contract.d.ts +2 -0
  63. package/dist/sharing/types.contract.d.ts.map +1 -0
  64. package/dist/sharing/types.contract.js +3 -0
  65. package/dist/sharing/types.contract.js.map +1 -0
  66. package/dist/sharing/types.d.ts +80 -0
  67. package/dist/sharing/types.d.ts.map +1 -0
  68. package/dist/sharing/types.js +3 -0
  69. package/dist/sharing/types.js.map +1 -0
  70. package/dist/skill/evaluator.d.ts.map +1 -1
  71. package/dist/skill/evaluator.js +2 -2
  72. package/dist/skill/evaluator.js.map +1 -1
  73. package/dist/skill/generator.d.ts.map +1 -1
  74. package/dist/skill/generator.js +4 -4
  75. package/dist/skill/generator.js.map +1 -1
  76. package/dist/skill/upgrader.js +1 -1
  77. package/dist/skill/upgrader.js.map +1 -1
  78. package/dist/skill/validator.js +1 -1
  79. package/dist/skill/validator.js.map +1 -1
  80. package/dist/storage/ensure-binding.d.ts.map +1 -1
  81. package/dist/storage/ensure-binding.js +3 -1
  82. package/dist/storage/ensure-binding.js.map +1 -1
  83. package/dist/storage/sqlite.d.ts +332 -1
  84. package/dist/storage/sqlite.d.ts.map +1 -1
  85. package/dist/storage/sqlite.js +913 -4
  86. package/dist/storage/sqlite.js.map +1 -1
  87. package/dist/tools/index.d.ts +1 -0
  88. package/dist/tools/index.d.ts.map +1 -1
  89. package/dist/tools/index.js +3 -1
  90. package/dist/tools/index.js.map +1 -1
  91. package/dist/tools/memory-search.d.ts +5 -2
  92. package/dist/tools/memory-search.d.ts.map +1 -1
  93. package/dist/tools/memory-search.js +50 -7
  94. package/dist/tools/memory-search.js.map +1 -1
  95. package/dist/tools/network-memory-detail.d.ts +4 -0
  96. package/dist/tools/network-memory-detail.d.ts.map +1 -0
  97. package/dist/tools/network-memory-detail.js +34 -0
  98. package/dist/tools/network-memory-detail.js.map +1 -0
  99. package/dist/types.d.ts +48 -2
  100. package/dist/types.d.ts.map +1 -1
  101. package/dist/types.js.map +1 -1
  102. package/dist/viewer/html.d.ts.map +1 -1
  103. package/dist/viewer/html.js +4299 -511
  104. package/dist/viewer/html.js.map +1 -1
  105. package/dist/viewer/server.d.ts +65 -0
  106. package/dist/viewer/server.d.ts.map +1 -1
  107. package/dist/viewer/server.js +1844 -90
  108. package/dist/viewer/server.js.map +1 -1
  109. package/index.ts +767 -41
  110. package/openclaw.plugin.json +3 -2
  111. package/package.json +3 -3
  112. package/scripts/postinstall.cjs +282 -45
  113. package/skill/memos-memory-guide/SKILL.md +82 -20
  114. package/src/capture/index.ts +30 -2
  115. package/src/client/connector.ts +225 -0
  116. package/src/client/hub.ts +207 -0
  117. package/src/client/skill-sync.ts +216 -0
  118. package/src/config.ts +92 -3
  119. package/src/embedding/index.ts +21 -1
  120. package/src/hub/auth.ts +78 -0
  121. package/src/hub/server.ts +906 -0
  122. package/src/hub/user-manager.ts +143 -0
  123. package/src/index.ts +13 -5
  124. package/src/ingest/providers/index.ts +240 -6
  125. package/src/ingest/providers/openai.ts +1 -1
  126. package/src/ingest/task-processor.ts +1 -1
  127. package/src/openclaw-api.ts +287 -0
  128. package/src/recall/engine.ts +1 -1
  129. package/src/shared/llm-call.ts +18 -2
  130. package/src/sharing/types.contract.ts +40 -0
  131. package/src/sharing/types.ts +102 -0
  132. package/src/skill/evaluator.ts +3 -2
  133. package/src/skill/generator.ts +6 -4
  134. package/src/skill/upgrader.ts +1 -1
  135. package/src/skill/validator.ts +1 -1
  136. package/src/storage/ensure-binding.ts +3 -1
  137. package/src/storage/sqlite.ts +1164 -4
  138. package/src/tools/index.ts +1 -0
  139. package/src/tools/memory-search.ts +58 -8
  140. package/src/tools/network-memory-detail.ts +34 -0
  141. package/src/types.ts +43 -2
  142. package/src/viewer/html.ts +4299 -511
  143. package/src/viewer/server.ts +1688 -73
@@ -14,7 +14,11 @@ import { vectorSearch } from "../storage/vector";
14
14
  import { TaskProcessor } from "../ingest/task-processor";
15
15
  import { RecallEngine } from "../recall/engine";
16
16
  import { SkillEvolver } from "../skill/evolver";
17
- import type { Logger, Chunk, PluginContext } from "../types";
17
+ import { resolveConfig } from "../config";
18
+ import { getHubStatus } from "../client/connector";
19
+ import { type ResolvedHubClient, hubGetMemoryDetail, hubListMemories, hubListTasks, hubListSkills, hubRequestJson, hubSearchMemories, hubSearchSkills, hubUpdateUsername, normalizeHubUrl, resolveHubClient } from "../client/hub";
20
+ import { buildSkillBundleForHub, fetchHubSkillBundle, restoreSkillBundleFromHub } from "../client/skill-sync";
21
+ import type { Logger, Chunk, PluginContext, MemosLocalConfig } from "../types";
18
22
  import { viewerHTML } from "./html";
19
23
  import { v4 as uuid } from "uuid";
20
24
 
@@ -80,6 +84,12 @@ export class ViewerServer {
80
84
  { running: false, done: false, stopped: false, processed: 0, total: 0, tasksCreated: 0, skillsCreated: 0, errors: 0, skippedSessions: 0, totalSessions: 0 };
81
85
  private ppSSEClients: http.ServerResponse[] = [];
82
86
 
87
+ private notifSSEClients: http.ServerResponse[] = [];
88
+ private notifPollTimer?: ReturnType<typeof setInterval>;
89
+ private lastKnownNotifCount = 0;
90
+ private hubHeartbeatTimer?: ReturnType<typeof setInterval>;
91
+ private static readonly HUB_HEARTBEAT_INTERVAL_MS = 45_000;
92
+
83
93
  constructor(opts: ViewerServerOptions) {
84
94
  this.store = opts.store;
85
95
  this.embedder = opts.embedder;
@@ -99,15 +109,16 @@ export class ViewerServer {
99
109
  this.server.on("error", (err: NodeJS.ErrnoException) => {
100
110
  if (err.code === "EADDRINUSE") {
101
111
  this.log.warn(`Viewer port ${this.port} in use, trying ${this.port + 1}`);
102
- this.server!.listen(this.port + 1, "127.0.0.1");
112
+ this.server!.listen(this.port + 1, "0.0.0.0");
103
113
  } else {
104
114
  reject(err);
105
115
  }
106
116
  });
107
- this.server.listen(this.port, "127.0.0.1", () => {
117
+ this.server.listen(this.port, "0.0.0.0", () => {
108
118
  const addr = this.server!.address();
109
119
  const actualPort = typeof addr === "object" && addr ? addr.port : this.port;
110
120
  this.autoCleanupPolluted();
121
+ this.startHubHeartbeat();
111
122
  resolve(`http://127.0.0.1:${actualPort}`);
112
123
  });
113
124
  });
@@ -130,6 +141,10 @@ export class ViewerServer {
130
141
  }
131
142
 
132
143
  stop(): void {
144
+ this.stopHubHeartbeat();
145
+ this.stopNotifPoll();
146
+ for (const c of this.notifSSEClients) { try { c.end(); } catch {} }
147
+ this.notifSSEClients = [];
133
148
  this.server?.close();
134
149
  this.server = null;
135
150
  }
@@ -220,7 +235,12 @@ export class ViewerServer {
220
235
  }
221
236
 
222
237
  if (p === "/api/memories" && req.method === "GET") this.serveMemories(res, url);
223
- else if (p === "/api/stats") this.serveStats(res);
238
+ else if (p === "/api/memories/share-local" && req.method === "POST") this.handleMemoryLocalShare(req, res);
239
+ else if (p === "/api/memories/unshare-local" && req.method === "POST") this.handleMemoryLocalUnshare(req, res);
240
+ else if (p.match(/^\/api\/memory\/[^/]+\/scope$/) && req.method === "PUT") this.handleMemoryScope(req, res, p);
241
+ else if (p.match(/^\/api\/task\/[^/]+\/scope$/) && req.method === "PUT") this.handleTaskScope(req, res, p);
242
+ else if (p.match(/^\/api\/skill\/[^/]+\/scope$/) && req.method === "PUT") this.handleSkillScope(req, res, p);
243
+ else if (p === "/api/stats") this.serveStats(res, url);
224
244
  else if (p === "/api/metrics") this.serveMetrics(res, url);
225
245
  else if (p === "/api/tool-metrics") this.serveToolMetrics(res, url);
226
246
  else if (p === "/api/search") this.serveSearch(req, res, url);
@@ -243,6 +263,43 @@ export class ViewerServer {
243
263
  else if (p === "/api/memories" && req.method === "DELETE") this.handleDeleteAll(res);
244
264
  else if (p === "/api/logs" && req.method === "GET") this.serveLogs(res, url);
245
265
  else if (p === "/api/log-tools" && req.method === "GET") this.serveLogTools(res);
266
+ else if (p === "/api/sharing/status" && req.method === "GET") this.serveSharingStatus(res);
267
+ else if (p === "/api/sharing/pending-users" && req.method === "GET") this.serveSharingPendingUsers(res);
268
+ else if (p === "/api/sharing/approve-user" && req.method === "POST") this.handleSharingApproveUser(req, res);
269
+ else if (p === "/api/sharing/reject-user" && req.method === "POST") this.handleSharingRejectUser(req, res);
270
+ else if (p === "/api/sharing/remove-user" && req.method === "POST") this.handleSharingRemoveUser(req, res);
271
+ else if (p === "/api/sharing/change-role" && req.method === "POST") this.handleSharingChangeRole(req, res);
272
+ else if (p === "/api/sharing/retry-join" && req.method === "POST") this.handleRetryJoin(req, res);
273
+ else if (p === "/api/sharing/search/memories" && req.method === "POST") this.handleSharingMemorySearch(req, res);
274
+ else if (p === "/api/sharing/memories/list" && req.method === "GET") this.serveSharingMemoryList(res, url);
275
+ else if (p === "/api/sharing/tasks/list" && req.method === "GET") this.serveSharingTaskList(res, url);
276
+ else if (p === "/api/sharing/skills/list" && req.method === "GET") this.serveSharingSkillList(res, url);
277
+ else if (p === "/api/sharing/memory-detail" && req.method === "POST") this.handleSharingMemoryDetail(req, res);
278
+ else if (p === "/api/sharing/search/skills" && req.method === "GET") this.serveSharingSkillSearch(res, url);
279
+ else if (p === "/api/sharing/tasks/share" && req.method === "POST") this.handleSharingTaskShare(req, res);
280
+ else if (p === "/api/sharing/tasks/unshare" && req.method === "POST") this.handleSharingTaskUnshare(req, res);
281
+ else if (p === "/api/sharing/update-username" && req.method === "POST") this.handleUpdateUsername(req, res);
282
+ else if (p === "/api/sharing/rename-user" && req.method === "POST") this.handleAdminRenameUser(req, res);
283
+ else if (p === "/api/sharing/test-hub" && req.method === "POST") this.handleTestHubConnection(req, res);
284
+ else if (p === "/api/sharing/memories/share" && req.method === "POST") this.handleSharingMemoryShare(req, res);
285
+ else if (p === "/api/sharing/memories/unshare" && req.method === "POST") this.handleSharingMemoryUnshare(req, res);
286
+ else if (p === "/api/sharing/skills/pull" && req.method === "POST") this.handleSharingSkillPull(req, res);
287
+ else if (p === "/api/sharing/skills/share" && req.method === "POST") this.handleSharingSkillShare(req, res);
288
+ else if (p === "/api/sharing/skills/unshare" && req.method === "POST") this.handleSharingSkillUnshare(req, res);
289
+ else if (p === "/api/sharing/users" && req.method === "GET") this.serveSharingUsers(res);
290
+ else if (p === "/api/sharing/notifications" && req.method === "GET") this.serveSharingNotifications(res, url);
291
+ else if (p === "/api/sharing/notifications/read" && req.method === "POST") this.handleSharingNotificationsRead(req, res);
292
+ else if (p === "/api/sharing/notifications/clear" && req.method === "POST") this.handleSharingNotificationsClear(req, res);
293
+ else if (p === "/api/notifications/stream" && req.method === "GET") this.handleNotifSSE(req, res);
294
+ else if (p === "/api/admin/shared-tasks" && req.method === "GET") this.serveAdminSharedTasks(res);
295
+ else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+\/detail$/) && req.method === "GET") this.serveHubTaskDetail(res, p);
296
+ else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteTask(res, p);
297
+ else if (p === "/api/admin/shared-skills" && req.method === "GET") this.serveAdminSharedSkills(res);
298
+ else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+\/detail$/) && req.method === "GET") this.serveHubSkillDetail(res, p);
299
+ else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteSkill(res, p);
300
+ else if (p === "/api/admin/shared-memories" && req.method === "GET") this.serveAdminSharedMemories(res);
301
+ else if (p.match(/^\/api\/admin\/shared-memories\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteMemory(res, p);
302
+ else if (p === "/api/local-ips" && req.method === "GET") this.serveLocalIPs(res);
246
303
  else if (p === "/api/config" && req.method === "GET") this.serveConfig(res);
247
304
  else if (p === "/api/config" && req.method === "PUT") this.handleSaveConfig(req, res);
248
305
  else if (p === "/api/test-model" && req.method === "POST") this.handleTestModel(req, res);
@@ -393,7 +450,13 @@ export class ViewerServer {
393
450
  const params: any[] = [];
394
451
  if (session) { conditions.push("session_key = ?"); params.push(session); }
395
452
  if (role) { conditions.push("role = ?"); params.push(role); }
396
- if (owner) { conditions.push("owner = ?"); params.push(owner); }
453
+ if (owner && owner.startsWith("agent:")) {
454
+ const agentPrefix = owner + ":";
455
+ conditions.push("(owner = ? OR (owner = 'public' AND session_key LIKE ?))");
456
+ params.push(owner, agentPrefix + "%");
457
+ } else if (owner) {
458
+ conditions.push("owner = ?"); params.push(owner);
459
+ }
397
460
  if (dateFrom) { conditions.push("created_at >= ?"); params.push(new Date(dateFrom).getTime()); }
398
461
  if (dateTo) { conditions.push("created_at <= ?"); params.push(new Date(dateTo).getTime()); }
399
462
 
@@ -401,15 +464,34 @@ export class ViewerServer {
401
464
  const totalRow = db.prepare("SELECT COUNT(*) as count FROM chunks" + where).get(...params) as any;
402
465
  const rawMemories = db.prepare("SELECT * FROM chunks" + where + ` ORDER BY created_at ${sortBy} LIMIT ? OFFSET ?`).all(...params, limit, offset);
403
466
  const findMergeSources = db.prepare("SELECT id, summary, role FROM chunks WHERE dedup_target = ? AND (dedup_status = 'merged' OR dedup_status = 'duplicate')");
404
- const memories = rawMemories.map((m: any) => {
405
- if (m.role === "user" && m.content) {
406
- m = { ...m, content: stripInboundMetadata(m.content) };
467
+
468
+ const chunkIds = rawMemories.map((m: any) => m.id);
469
+ const sharingMap = new Map<string, { visibility: string; group_id: string | null }>();
470
+ const localShareMap = new Map<string, { original_owner: string; shared_at: number }>();
471
+ if (chunkIds.length > 0) {
472
+ try {
473
+ const placeholders = chunkIds.map(() => "?").join(",");
474
+ const sharedRows = db.prepare(`SELECT source_chunk_id, visibility, group_id FROM hub_memories WHERE source_chunk_id IN (${placeholders})`).all(...chunkIds) as Array<{ source_chunk_id: string; visibility: string; group_id: string | null }>;
475
+ for (const r of sharedRows) sharingMap.set(r.source_chunk_id, r);
476
+ const localRows = db.prepare(`SELECT chunk_id, original_owner, shared_at FROM local_shared_memories WHERE chunk_id IN (${placeholders})`).all(...chunkIds) as Array<{ chunk_id: string; original_owner: string; shared_at: number }>;
477
+ for (const r of localRows) localShareMap.set(r.chunk_id, r);
478
+ } catch {
407
479
  }
408
- if (m.merge_count > 0) {
480
+ }
481
+ const memories = rawMemories.map((m: any) => {
482
+ const out: any = m.role === "user" && m.content ? { ...m, content: stripInboundMetadata(m.content) } : { ...m };
483
+ if (out.merge_count > 0) {
409
484
  const sources = findMergeSources.all(m.id) as Array<{ id: string; summary: string; role: string }>;
410
- m.merge_sources = sources;
485
+ out.merge_sources = sources;
411
486
  }
412
- return m;
487
+ const shared = sharingMap.get(m.id);
488
+ const localShared = localShareMap.get(m.id);
489
+ out.sharingVisibility = shared?.visibility ?? null;
490
+ out.sharingGroupId = shared?.group_id ?? null;
491
+ out.localSharing = out.owner === "public";
492
+ out.localSharingManaged = !!localShared;
493
+ out.localOriginalOwner = localShared?.original_owner ?? null;
494
+ return out;
413
495
  });
414
496
 
415
497
  this.store.recordViewerEvent("list");
@@ -426,7 +508,21 @@ export class ViewerServer {
426
508
  }
427
509
 
428
510
  private serveToolMetrics(res: http.ServerResponse, url: URL): void {
429
- const minutes = Math.min(1440, Math.max(10, Number(url.searchParams.get("minutes")) || 60));
511
+ const fromParam = url.searchParams.get("from");
512
+ const toParam = url.searchParams.get("to");
513
+ if (fromParam) {
514
+ const fromMs = new Date(fromParam).getTime();
515
+ const toMs = toParam ? new Date(toParam).getTime() : Date.now();
516
+ if (isNaN(fromMs) || isNaN(toMs)) {
517
+ this.jsonResponse(res, { error: "Invalid date" }, 400);
518
+ return;
519
+ }
520
+ const diffMin = Math.max(10, Math.min(43200, Math.round((toMs - fromMs) / 60000)));
521
+ const data = this.store.getToolMetrics(diffMin, fromMs, toMs);
522
+ this.jsonResponse(res, data);
523
+ return;
524
+ }
525
+ const minutes = Math.min(43200, Math.max(10, Number(url.searchParams.get("minutes")) || 60));
430
526
  const data = this.store.getToolMetrics(minutes);
431
527
  this.jsonResponse(res, data);
432
528
  }
@@ -434,13 +530,15 @@ export class ViewerServer {
434
530
  private serveTasks(res: http.ServerResponse, url: URL): void {
435
531
  this.store.recordViewerEvent("tasks_list");
436
532
  const status = url.searchParams.get("status") ?? undefined;
533
+ const owner = url.searchParams.get("owner") ?? undefined;
437
534
  const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit")) || 50));
438
535
  const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
439
- const { tasks, total } = this.store.listTasks({ status, limit, offset });
536
+ const { tasks, total } = this.store.listTasks({ status, limit, offset, owner });
440
537
 
441
538
  const db = (this.store as any).db;
442
539
  const items = tasks.map((t) => {
443
- const meta = db.prepare("SELECT skill_status FROM tasks WHERE id = ?").get(t.id) as { skill_status: string | null } | undefined;
540
+ const meta = db.prepare("SELECT skill_status, owner FROM tasks WHERE id = ?").get(t.id) as { skill_status: string | null; owner: string | null } | undefined;
541
+ const sharedTask = db.prepare("SELECT visibility FROM hub_tasks WHERE source_task_id = ? ORDER BY updated_at DESC LIMIT 1").get(t.id) as { visibility: string } | undefined;
444
542
  return {
445
543
  id: t.id,
446
544
  sessionKey: t.sessionKey,
@@ -451,6 +549,8 @@ export class ViewerServer {
451
549
  endedAt: t.endedAt,
452
550
  chunkCount: this.store.countChunksByTask(t.id),
453
551
  skillStatus: meta?.skill_status ?? null,
552
+ owner: meta?.owner ?? "agent:main",
553
+ sharingVisibility: sharedTask?.visibility ?? null,
454
554
  };
455
555
  });
456
556
 
@@ -485,6 +585,7 @@ export class ViewerServer {
485
585
  const db = (this.store as any).db;
486
586
  const meta = db.prepare("SELECT skill_status, skill_reason FROM tasks WHERE id = ?").get(taskId) as
487
587
  { skill_status: string | null; skill_reason: string | null } | undefined;
588
+ const sharedTask = db.prepare("SELECT visibility, group_id FROM hub_tasks WHERE source_task_id = ? ORDER BY updated_at DESC LIMIT 1").get(taskId) as { visibility: string | null; group_id: string | null } | undefined;
488
589
 
489
590
  this.jsonResponse(res, {
490
591
  id: task.id,
@@ -492,16 +593,20 @@ export class ViewerServer {
492
593
  title: task.title,
493
594
  summary: task.summary,
494
595
  status: task.status,
596
+ owner: task.owner ?? "agent:main",
495
597
  startedAt: task.startedAt,
496
598
  endedAt: task.endedAt,
497
599
  chunks: chunkItems,
498
600
  skillStatus: meta?.skill_status ?? null,
499
601
  skillReason: meta?.skill_reason ?? null,
500
602
  skillLinks,
603
+ sharingVisibility: sharedTask?.visibility ?? null,
604
+ sharingGroupId: sharedTask?.group_id ?? null,
605
+ hubTaskId: sharedTask ? true : false,
501
606
  });
502
607
  }
503
608
 
504
- private serveStats(res: http.ServerResponse): void {
609
+ private serveStats(res: http.ServerResponse, url?: URL): void {
505
610
  const emptyStats = {
506
611
  totalMemories: 0, totalSessions: 0, totalEmbeddings: 0, totalSkills: 0,
507
612
  embeddingProvider: this.embedder?.provider ?? "none",
@@ -515,6 +620,8 @@ export class ViewerServer {
515
620
  return;
516
621
  }
517
622
 
623
+ const ownerFilter = url?.searchParams.get("owner") ?? "";
624
+
518
625
  try {
519
626
  const db = (this.store as any).db;
520
627
  const total = db.prepare("SELECT COUNT(*) as count FROM chunks").get() as any;
@@ -530,9 +637,20 @@ export class ViewerServer {
530
637
  }
531
638
  let embCount = 0;
532
639
  try { embCount = (db.prepare("SELECT COUNT(*) as count FROM embeddings").get() as any).count; } catch { /* table may not exist */ }
533
- const sessionList = db.prepare(
534
- "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks GROUP BY session_key ORDER BY latest DESC",
535
- ).all() as any[];
640
+ let sessionQuery: string;
641
+ let sessionParams: any[];
642
+ if (ownerFilter && ownerFilter.startsWith("agent:")) {
643
+ const agentPrefix = ownerFilter + ":";
644
+ sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks WHERE (owner = ? OR (owner = 'public' AND session_key LIKE ?)) GROUP BY session_key ORDER BY latest DESC";
645
+ sessionParams = [ownerFilter, agentPrefix + "%"];
646
+ } else if (ownerFilter) {
647
+ sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks WHERE owner = ? GROUP BY session_key ORDER BY latest DESC";
648
+ sessionParams = [ownerFilter];
649
+ } else {
650
+ sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks GROUP BY session_key ORDER BY latest DESC";
651
+ sessionParams = [];
652
+ }
653
+ const sessionList = db.prepare(sessionQuery).all(...sessionParams) as any[];
536
654
 
537
655
  let skillCount = 0;
538
656
  try { skillCount = (db.prepare("SELECT COUNT(*) as count FROM skills").get() as any).count; } catch { /* table may not exist yet */ }
@@ -545,7 +663,7 @@ export class ViewerServer {
545
663
 
546
664
  let owners: string[] = [];
547
665
  try {
548
- const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL ORDER BY owner").all() as any[];
666
+ const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL AND owner LIKE 'agent:%' ORDER BY owner").all() as any[];
549
667
  owners = ownerRows.map((o: any) => o.owner);
550
668
  } catch { /* column may not exist yet */ }
551
669
 
@@ -668,7 +786,12 @@ export class ViewerServer {
668
786
  if (visibility) {
669
787
  skills = skills.filter(s => s.visibility === visibility);
670
788
  }
671
- this.jsonResponse(res, { skills });
789
+ const db = (this.store as any).db;
790
+ const enriched = skills.map(s => {
791
+ const hub = db.prepare("SELECT visibility FROM hub_skills WHERE source_skill_id = ? ORDER BY updated_at DESC LIMIT 1").get(s.id) as { visibility: string } | undefined;
792
+ return { ...s, sharingVisibility: hub?.visibility ?? null };
793
+ });
794
+ this.jsonResponse(res, { skills: enriched });
672
795
  }
673
796
 
674
797
  private serveSkillDetail(res: http.ServerResponse, urlPath: string): void {
@@ -684,8 +807,11 @@ export class ViewerServer {
684
807
  const relatedTasks = this.store.getTasksBySkill(skillId);
685
808
  const files = fs.existsSync(skill.dirPath) ? this.walkDir(skill.dirPath, skill.dirPath) : [];
686
809
 
810
+ const db = (this.store as any).db;
811
+ const sharedSkill = db.prepare("SELECT visibility, group_id FROM hub_skills WHERE source_skill_id = ? ORDER BY updated_at DESC LIMIT 1").get(skillId) as { visibility: string | null; group_id: string | null } | undefined;
812
+
687
813
  this.jsonResponse(res, {
688
- skill,
814
+ skill: { ...skill, sharingVisibility: sharedSkill?.visibility ?? null, sharingGroupId: sharedSkill?.group_id ?? null },
689
815
  versions: versions.map(v => ({
690
816
  id: v.id,
691
817
  version: v.version,
@@ -796,7 +922,7 @@ export class ViewerServer {
796
922
  private handleSkillVisibility(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
797
923
  const segments = urlPath.split("/");
798
924
  const skillId = segments[segments.length - 2];
799
- this.readBody(req, (body) => {
925
+ this.readBody(req, async (body) => {
800
926
  try {
801
927
  const parsed = JSON.parse(body);
802
928
  const visibility = parsed.visibility;
@@ -812,7 +938,46 @@ export class ViewerServer {
812
938
  return;
813
939
  }
814
940
  this.store.setSkillVisibility(skillId, visibility);
815
- this.jsonResponse(res, { ok: true, skillId, visibility });
941
+
942
+ let hubSynced = false;
943
+ const sharing = this.ctx?.config?.sharing;
944
+ if (sharing?.enabled && this.ctx) {
945
+ try {
946
+ const hubClient = await this.resolveHubClientAware();
947
+ if (visibility === "public") {
948
+ const bundle = buildSkillBundleForHub(this.store, skillId);
949
+ const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/publish", {
950
+ method: "POST",
951
+ body: JSON.stringify({ visibility: "public", groupId: null, metadata: bundle.metadata, bundle: bundle.bundle }),
952
+ }) as any;
953
+ if (hubClient.userId) {
954
+ const existing = this.store.getHubSkillBySource(hubClient.userId, skillId);
955
+ this.store.upsertHubSkill({
956
+ id: response?.skillId ?? existing?.id ?? crypto.randomUUID(),
957
+ sourceSkillId: skillId, sourceUserId: hubClient.userId,
958
+ name: skill.name, description: skill.description, version: skill.version,
959
+ groupId: null, visibility: "public",
960
+ bundle: JSON.stringify(bundle.bundle), qualityScore: skill.qualityScore,
961
+ createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
962
+ });
963
+ }
964
+ hubSynced = true;
965
+ this.log.info(`Skill "${skill.name}" published to Hub`);
966
+ } else {
967
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/unpublish", {
968
+ method: "POST",
969
+ body: JSON.stringify({ sourceSkillId: skillId }),
970
+ });
971
+ if (hubClient.userId) this.store.deleteHubSkillBySource(hubClient.userId, skillId);
972
+ hubSynced = true;
973
+ this.log.info(`Skill "${skill.name}" unpublished from Hub`);
974
+ }
975
+ } catch (hubErr) {
976
+ this.log.warn(`Hub sync failed for skill visibility change: ${hubErr}`);
977
+ }
978
+ }
979
+
980
+ this.jsonResponse(res, { ok: true, skillId, visibility, hubSynced });
816
981
  } catch (err) {
817
982
  const errMsg = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
818
983
  this.log.error(`handleSkillVisibility error: skillId=${skillId}, body=${body}, err=${errMsg}`);
@@ -928,7 +1093,15 @@ export class ViewerServer {
928
1093
  const cleaned = chunk.role === "user" && chunk.content
929
1094
  ? { ...chunk, content: stripInboundMetadata(chunk.content) }
930
1095
  : chunk;
931
- this.jsonResponse(res, { memory: cleaned });
1096
+ const localShared = this.store.getLocalSharedMemory(chunkId);
1097
+ this.jsonResponse(res, {
1098
+ memory: {
1099
+ ...cleaned,
1100
+ localSharing: cleaned.owner === "public",
1101
+ localSharingManaged: !!localShared,
1102
+ localOriginalOwner: localShared?.originalOwner ?? null,
1103
+ },
1104
+ });
932
1105
  }
933
1106
 
934
1107
  private handleUpdate(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
@@ -957,6 +1130,340 @@ export class ViewerServer {
957
1130
  else { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found" })); }
958
1131
  }
959
1132
 
1133
+ private handleMemoryLocalShare(req: http.IncomingMessage, res: http.ServerResponse): void {
1134
+ this.readBody(req, (body) => {
1135
+ try {
1136
+ const parsed = JSON.parse(body || "{}");
1137
+ const chunkId = String(parsed.chunkId || "");
1138
+ if (!chunkId) return this.jsonResponse(res, { ok: false, error: "missing_chunk_id" }, 400);
1139
+ const result = this.store.markMemorySharedLocally(chunkId);
1140
+ if (!result.ok) {
1141
+ return this.jsonResponse(res, { ok: false, error: result.reason ?? "share_failed" }, result.reason === "not_found" ? 404 : 400);
1142
+ }
1143
+ this.jsonResponse(res, {
1144
+ ok: true,
1145
+ chunkId,
1146
+ owner: result.owner,
1147
+ localSharing: true,
1148
+ localSharingManaged: true,
1149
+ localOriginalOwner: result.originalOwner ?? null,
1150
+ });
1151
+ } catch (err) {
1152
+ this.jsonResponse(res, { ok: false, error: String(err) }, 400);
1153
+ }
1154
+ });
1155
+ }
1156
+
1157
+ private handleMemoryLocalUnshare(req: http.IncomingMessage, res: http.ServerResponse): void {
1158
+ this.readBody(req, (body) => {
1159
+ try {
1160
+ const parsed = JSON.parse(body || "{}");
1161
+ const chunkId = String(parsed.chunkId || "");
1162
+ const privateOwner = typeof parsed.privateOwner === "string" ? parsed.privateOwner : undefined;
1163
+ if (!chunkId) return this.jsonResponse(res, { ok: false, error: "missing_chunk_id" }, 400);
1164
+ const result = this.store.unmarkMemorySharedLocally(chunkId, privateOwner);
1165
+ if (!result.ok) {
1166
+ return this.jsonResponse(res, { ok: false, error: result.reason ?? "unshare_failed" }, result.reason === "not_found" ? 404 : 400);
1167
+ }
1168
+ this.jsonResponse(res, {
1169
+ ok: true,
1170
+ chunkId,
1171
+ owner: result.owner,
1172
+ localSharing: false,
1173
+ localOriginalOwner: result.originalOwner ?? null,
1174
+ });
1175
+ } catch (err) {
1176
+ this.jsonResponse(res, { ok: false, error: String(err) }, 400);
1177
+ }
1178
+ });
1179
+ }
1180
+
1181
+ // ─── Unified scope API ───
1182
+
1183
+ private handleMemoryScope(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
1184
+ const chunkId = urlPath.split("/")[3];
1185
+ this.readBody(req, async (body) => {
1186
+ try {
1187
+ const parsed = JSON.parse(body || "{}");
1188
+ const scope = parsed.scope as string;
1189
+ if (!["private", "local", "team"].includes(scope)) {
1190
+ return this.jsonResponse(res, { ok: false, error: "scope must be 'private', 'local', or 'team'" }, 400);
1191
+ }
1192
+ const db = (this.store as any).db;
1193
+ const chunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId) as any;
1194
+ if (!chunk) return this.jsonResponse(res, { ok: false, error: "not_found" }, 404);
1195
+
1196
+ if (chunk.dedup_status && chunk.dedup_status !== "active") {
1197
+ return this.jsonResponse(res, { ok: false, error: "inactive_memory", message: "Merged/duplicate memories cannot be shared" }, 400);
1198
+ }
1199
+
1200
+ const isLocalShared = chunk.owner === "public";
1201
+ const hubMemory = this.getHubMemoryForChunk(chunkId);
1202
+ const isTeamShared = !!hubMemory;
1203
+ const currentScope = isTeamShared ? "team" : isLocalShared ? "local" : "private";
1204
+
1205
+ if (scope === currentScope) {
1206
+ return this.jsonResponse(res, { ok: true, scope, changed: false });
1207
+ }
1208
+
1209
+ let hubSynced = false;
1210
+
1211
+ if (scope === "team") {
1212
+ if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
1213
+ if (!isTeamShared) {
1214
+ const hubClient = await this.resolveHubClientAware();
1215
+ const refreshedChunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId) as any;
1216
+ const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/share", {
1217
+ method: "POST",
1218
+ body: JSON.stringify({ memory: { sourceChunkId: refreshedChunk.id, role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary, kind: refreshedChunk.kind, groupId: null, visibility: "public" } }),
1219
+ });
1220
+ if (hubClient.userId) {
1221
+ const existing = this.store.getHubMemoryBySource(hubClient.userId, chunkId);
1222
+ this.store.upsertHubMemory({
1223
+ id: (response as any)?.memoryId ?? existing?.id ?? crypto.randomUUID(),
1224
+ sourceChunkId: chunkId, sourceUserId: hubClient.userId,
1225
+ role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary ?? "",
1226
+ kind: refreshedChunk.kind, groupId: null, visibility: "public",
1227
+ createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1228
+ });
1229
+ }
1230
+ hubSynced = true;
1231
+ }
1232
+ } else if (scope === "local") {
1233
+ if (isTeamShared) {
1234
+ try {
1235
+ const hubClient = await this.resolveHubClientAware();
1236
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/unshare", {
1237
+ method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
1238
+ });
1239
+ if (hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1240
+ hubSynced = true;
1241
+ } catch (err) { this.log.warn(`Failed to unshare memory from team: ${err}`); }
1242
+ }
1243
+ if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
1244
+ } else {
1245
+ if (isTeamShared) {
1246
+ try {
1247
+ const hubClient = await this.resolveHubClientAware();
1248
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/unshare", {
1249
+ method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
1250
+ });
1251
+ if (hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1252
+ hubSynced = true;
1253
+ } catch (err) { this.log.warn(`Failed to unshare memory from team: ${err}`); }
1254
+ }
1255
+ if (isLocalShared) this.store.unmarkMemorySharedLocally(chunkId);
1256
+ }
1257
+
1258
+ this.jsonResponse(res, { ok: true, scope, changed: true, hubSynced });
1259
+ } catch (err) {
1260
+ this.jsonResponse(res, { ok: false, error: String(err) }, 500);
1261
+ }
1262
+ });
1263
+ }
1264
+
1265
+ private handleTaskScope(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
1266
+ const taskId = urlPath.split("/")[3];
1267
+ this.readBody(req, async (body) => {
1268
+ try {
1269
+ const parsed = JSON.parse(body || "{}");
1270
+ const scope = parsed.scope as string;
1271
+ if (!["private", "local", "team"].includes(scope)) {
1272
+ return this.jsonResponse(res, { ok: false, error: "scope must be 'private', 'local', or 'team'" }, 400);
1273
+ }
1274
+ const task = this.store.getTask(taskId);
1275
+ if (!task) return this.jsonResponse(res, { ok: false, error: "task_not_found" }, 404);
1276
+
1277
+ if (scope !== "private" && task.status !== "completed") {
1278
+ return this.jsonResponse(res, { ok: false, error: "only_completed_tasks_can_be_shared" }, 400);
1279
+ }
1280
+
1281
+ const isLocalShared = task.owner === "public";
1282
+ const hubTask = this.getHubTaskForLocal(taskId);
1283
+ const isTeamShared = !!hubTask;
1284
+ const currentScope = isTeamShared ? "team" : isLocalShared ? "local" : "private";
1285
+
1286
+ if (scope === currentScope) {
1287
+ return this.jsonResponse(res, { ok: true, scope, changed: false });
1288
+ }
1289
+
1290
+ let hubSynced = false;
1291
+
1292
+ if (scope === "local" || scope === "team") {
1293
+ if (!isLocalShared) {
1294
+ const originalOwner = task.owner;
1295
+ const db = (this.store as any).db;
1296
+ db.prepare("INSERT INTO local_shared_tasks (task_id, hub_task_id, original_owner, shared_at) VALUES (?, ?, ?, ?) ON CONFLICT(task_id) DO UPDATE SET original_owner = excluded.original_owner, shared_at = excluded.shared_at").run(taskId, "", originalOwner, Date.now());
1297
+ db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1298
+ }
1299
+ }
1300
+
1301
+ if (scope === "team") {
1302
+ if (!isTeamShared) {
1303
+ const chunks = this.store.getChunksByTask(taskId);
1304
+ const hubClient = await this.resolveHubClientAware();
1305
+ const refreshedTask = this.store.getTask(taskId)!;
1306
+ const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/share", {
1307
+ method: "POST",
1308
+ body: JSON.stringify({
1309
+ task: { id: refreshedTask.id, sourceTaskId: refreshedTask.id, title: refreshedTask.title, summary: refreshedTask.summary, groupId: null, visibility: "public", createdAt: refreshedTask.startedAt ?? Date.now(), updatedAt: refreshedTask.updatedAt ?? Date.now() },
1310
+ chunks: chunks.map((c) => ({ id: c.id, hubTaskId: refreshedTask.id, sourceTaskId: refreshedTask.id, sourceChunkId: c.id, role: c.role, content: c.content, summary: c.summary, kind: c.kind, groupId: null, visibility: "public", createdAt: c.createdAt ?? Date.now() })),
1311
+ }),
1312
+ });
1313
+ if (hubClient.userId) {
1314
+ const existing = this.store.getHubTaskBySource(hubClient.userId, taskId);
1315
+ this.store.upsertHubTask({
1316
+ id: (response as any)?.taskId ?? existing?.id ?? crypto.randomUUID(),
1317
+ sourceTaskId: taskId, sourceUserId: hubClient.userId, title: refreshedTask.title ?? "",
1318
+ summary: refreshedTask.summary ?? "", groupId: null, visibility: "public",
1319
+ createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1320
+ });
1321
+ }
1322
+ hubSynced = true;
1323
+ }
1324
+ }
1325
+
1326
+ if (scope === "local" && isTeamShared) {
1327
+ try {
1328
+ const hubClient = await this.resolveHubClientAware();
1329
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/unshare", {
1330
+ method: "POST", body: JSON.stringify({ sourceTaskId: taskId }),
1331
+ });
1332
+ if (hubClient.userId) this.store.deleteHubTaskBySource(hubClient.userId, taskId);
1333
+ hubSynced = true;
1334
+ } catch (err) { this.log.warn(`Failed to unshare task from team: ${err}`); }
1335
+ }
1336
+
1337
+ if (scope === "private") {
1338
+ if (isTeamShared) {
1339
+ try {
1340
+ const hubClient = await this.resolveHubClientAware();
1341
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/unshare", {
1342
+ method: "POST", body: JSON.stringify({ sourceTaskId: taskId }),
1343
+ });
1344
+ if (hubClient.userId) this.store.deleteHubTaskBySource(hubClient.userId, taskId);
1345
+ hubSynced = true;
1346
+ } catch (err) { this.log.warn(`Failed to unshare task from team: ${err}`); }
1347
+ }
1348
+ if (isLocalShared) {
1349
+ const db = (this.store as any).db;
1350
+ const shared = db.prepare("SELECT original_owner FROM local_shared_tasks WHERE task_id = ?").get(taskId) as any;
1351
+ const restoreOwner = shared?.original_owner ?? task.owner;
1352
+ if (restoreOwner && restoreOwner !== "public") {
1353
+ db.prepare("UPDATE tasks SET owner = ? WHERE id = ?").run(restoreOwner, taskId);
1354
+ }
1355
+ db.prepare("DELETE FROM local_shared_tasks WHERE task_id = ?").run(taskId);
1356
+ }
1357
+ }
1358
+
1359
+ this.jsonResponse(res, { ok: true, scope, changed: true, hubSynced });
1360
+ } catch (err) {
1361
+ this.jsonResponse(res, { ok: false, error: String(err) }, 500);
1362
+ }
1363
+ });
1364
+ }
1365
+
1366
+ private handleSkillScope(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
1367
+ const skillId = urlPath.split("/")[3];
1368
+ this.readBody(req, async (body) => {
1369
+ try {
1370
+ const parsed = JSON.parse(body || "{}");
1371
+ const scope = parsed.scope as string;
1372
+ if (!["private", "local", "team"].includes(scope)) {
1373
+ return this.jsonResponse(res, { ok: false, error: "scope must be 'private', 'local', or 'team'" }, 400);
1374
+ }
1375
+ const skill = this.store.getSkill(skillId);
1376
+ if (!skill) return this.jsonResponse(res, { ok: false, error: "skill_not_found" }, 404);
1377
+
1378
+ if (scope !== "private" && skill.status !== "active") {
1379
+ return this.jsonResponse(res, { ok: false, error: "only_active_skills_can_be_shared" }, 400);
1380
+ }
1381
+
1382
+ const isLocalShared = skill.visibility === "public";
1383
+ const hubSkill = this.getHubSkillForLocal(skillId);
1384
+ const isTeamShared = !!hubSkill;
1385
+ const currentScope = isTeamShared ? "team" : isLocalShared ? "local" : "private";
1386
+
1387
+ if (scope === currentScope) {
1388
+ return this.jsonResponse(res, { ok: true, scope, changed: false });
1389
+ }
1390
+
1391
+ let hubSynced = false;
1392
+
1393
+ if (scope === "local" || scope === "team") {
1394
+ if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
1395
+ }
1396
+
1397
+ if (scope === "team") {
1398
+ if (!isTeamShared) {
1399
+ const bundle = buildSkillBundleForHub(this.store, skillId);
1400
+ const hubClient = await this.resolveHubClientAware();
1401
+ const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/publish", {
1402
+ method: "POST",
1403
+ body: JSON.stringify({ visibility: "public", groupId: null, metadata: bundle.metadata, bundle: bundle.bundle }),
1404
+ });
1405
+ if (hubClient.userId) {
1406
+ const existing = this.store.getHubSkillBySource(hubClient.userId, skillId);
1407
+ this.store.upsertHubSkill({
1408
+ id: (response as any)?.skillId ?? existing?.id ?? crypto.randomUUID(),
1409
+ sourceSkillId: skillId, sourceUserId: hubClient.userId,
1410
+ name: skill.name, description: skill.description, version: skill.version,
1411
+ groupId: null, visibility: "public",
1412
+ bundle: JSON.stringify(bundle.bundle), qualityScore: skill.qualityScore,
1413
+ createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1414
+ });
1415
+ }
1416
+ hubSynced = true;
1417
+ }
1418
+ }
1419
+
1420
+ if (scope === "local" && isTeamShared) {
1421
+ try {
1422
+ const hubClient = await this.resolveHubClientAware();
1423
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/unpublish", {
1424
+ method: "POST", body: JSON.stringify({ sourceSkillId: skillId }),
1425
+ });
1426
+ if (hubClient.userId) this.store.deleteHubSkillBySource(hubClient.userId, skillId);
1427
+ hubSynced = true;
1428
+ } catch (err) { this.log.warn(`Failed to unpublish skill from team: ${err}`); }
1429
+ }
1430
+
1431
+ if (scope === "private") {
1432
+ if (isTeamShared) {
1433
+ try {
1434
+ const hubClient = await this.resolveHubClientAware();
1435
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/unpublish", {
1436
+ method: "POST", body: JSON.stringify({ sourceSkillId: skillId }),
1437
+ });
1438
+ if (hubClient.userId) this.store.deleteHubSkillBySource(hubClient.userId, skillId);
1439
+ hubSynced = true;
1440
+ } catch (err) { this.log.warn(`Failed to unpublish skill from team: ${err}`); }
1441
+ }
1442
+ if (isLocalShared) this.store.setSkillVisibility(skillId, "private");
1443
+ }
1444
+
1445
+ this.jsonResponse(res, { ok: true, scope, changed: true, hubSynced });
1446
+ } catch (err) {
1447
+ this.jsonResponse(res, { ok: false, error: String(err) }, 500);
1448
+ }
1449
+ });
1450
+ }
1451
+
1452
+ private getHubMemoryForChunk(chunkId: string): any {
1453
+ const db = (this.store as any).db;
1454
+ return db.prepare("SELECT * FROM hub_memories WHERE source_chunk_id = ? LIMIT 1").get(chunkId);
1455
+ }
1456
+
1457
+ private getHubTaskForLocal(taskId: string): any {
1458
+ const db = (this.store as any).db;
1459
+ return db.prepare("SELECT * FROM hub_tasks WHERE source_task_id = ? LIMIT 1").get(taskId);
1460
+ }
1461
+
1462
+ private getHubSkillForLocal(skillId: string): any {
1463
+ const db = (this.store as any).db;
1464
+ return db.prepare("SELECT * FROM hub_skills WHERE source_skill_id = ? LIMIT 1").get(skillId);
1465
+ }
1466
+
960
1467
  private handleDeleteSession(res: http.ServerResponse, url: URL): void {
961
1468
  const key = url.searchParams.get("key");
962
1469
  if (!key) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Missing key" })); return; }
@@ -996,69 +1503,1077 @@ export class ViewerServer {
996
1503
  return path.join(ocHome, "openclaw.json");
997
1504
  }
998
1505
 
999
- private serveConfig(res: http.ServerResponse): void {
1506
+ private getPluginEntryConfig(raw: any): Record<string, unknown> {
1507
+ const entries = raw?.plugins?.entries ?? {};
1508
+ return entries["memos-local-openclaw-plugin"]?.config
1509
+ ?? entries["memos-lite-openclaw-plugin"]?.config
1510
+ ?? entries["memos-lite"]?.config
1511
+ ?? {};
1512
+ }
1513
+
1514
+ private getResolvedViewerConfig(raw?: any): MemosLocalConfig {
1515
+ const pluginCfg = this.getPluginEntryConfig(raw);
1516
+ const stateDir = this.ctx?.stateDir ?? this.getOpenClawHome();
1517
+ return resolveConfig(pluginCfg as Partial<MemosLocalConfig>, stateDir);
1518
+ }
1519
+
1520
+ private hasUsableEmbeddingProvider(cfg: MemosLocalConfig): boolean {
1521
+ const embedding = cfg.embedding;
1522
+ if (!embedding?.provider) return false;
1523
+ if (embedding.provider === "openclaw") {
1524
+ return !!(this.ctx?.openclawAPI) && embedding.capabilities?.hostEmbedding === true;
1525
+ }
1526
+ return true;
1527
+ }
1528
+
1529
+ private hasUsableSummarizerProvider(cfg: MemosLocalConfig): boolean {
1530
+ const summarizer = cfg.summarizer;
1531
+ if (!summarizer?.provider) return false;
1532
+ if (summarizer.provider === "openclaw") {
1533
+ return !!(this.ctx?.openclawAPI) && summarizer.capabilities?.hostCompletion === true;
1534
+ }
1535
+ return true;
1536
+ }
1537
+
1538
+ private async serveSharingStatus(res: http.ServerResponse): Promise<void> {
1539
+ const sharing = this.ctx?.config?.sharing;
1540
+ const persisted = this.store.getClientHubConnection();
1541
+ const resolvedHubUrl = sharing?.client?.hubAddress ? normalizeHubUrl(sharing.client.hubAddress) : persisted?.hubUrl ?? null;
1542
+ const hasClientConfig = Boolean(
1543
+ (sharing?.client?.hubAddress && sharing?.client?.userToken) ||
1544
+ (persisted?.hubUrl && persisted?.userToken),
1545
+ );
1546
+ const base = {
1547
+ enabled: Boolean(sharing?.enabled),
1548
+ role: sharing?.role ?? null,
1549
+ clientConfigured: hasClientConfig,
1550
+ hubUrl: resolvedHubUrl,
1551
+ connection: { connected: false, user: null as any, hubUrl: undefined as string | undefined, teamName: null as string | null, apiVersion: null as string | null },
1552
+ admin: { canManageUsers: false, rejectSupported: false },
1553
+ };
1554
+
1555
+ if (!this.ctx || !sharing?.enabled) {
1556
+ this.jsonResponse(res, base);
1557
+ return;
1558
+ }
1559
+
1560
+ // Hub 模式下,本机就是管理者,直接赋予 admin 权限
1561
+ if (sharing.role === "hub") {
1562
+ base.admin.canManageUsers = true;
1563
+ base.admin.rejectSupported = true;
1564
+ base.connection.connected = true;
1565
+ base.connection.hubUrl = resolvedHubUrl ?? undefined;
1566
+
1567
+ let adminUser: any = { username: "hub-admin", role: "admin" };
1568
+ try {
1569
+ const hub = this.resolveHubConnection();
1570
+ if (hub) {
1571
+ const me = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/me", { method: "GET" }) as any;
1572
+ if (me) {
1573
+ adminUser = {
1574
+ id: me.id,
1575
+ username: me.username ?? "hub-admin",
1576
+ role: me.role ?? "admin",
1577
+ };
1578
+ }
1579
+ }
1580
+ } catch { /* fallback to default */ }
1581
+ base.connection.user = adminUser;
1582
+
1583
+ // Fetch team info from own hub
1584
+ try {
1585
+ const selfUrl = resolvedHubUrl || `http://localhost:${sharing.hub?.port ?? 21816}`;
1586
+ const info = await fetch(`${selfUrl}/api/v1/hub/info`).then(r => r.ok ? r.json() : null).catch(() => null) as any;
1587
+ base.connection.teamName = info?.teamName ?? sharing.hub?.teamName ?? null;
1588
+ base.connection.apiVersion = info?.apiVersion ?? null;
1589
+ } catch { /* ignore */ }
1590
+ this.jsonResponse(res, base);
1591
+ return;
1592
+ }
1593
+
1594
+ const hasPendingConnection = Boolean(persisted?.hubUrl && persisted?.userId && !persisted?.userToken);
1595
+ if (!hasClientConfig && !hasPendingConnection) {
1596
+ this.jsonResponse(res, base);
1597
+ return;
1598
+ }
1599
+
1000
1600
  try {
1001
- const cfgPath = this.getOpenClawConfigPath();
1002
- if (!fs.existsSync(cfgPath)) {
1003
- this.jsonResponse(res, {});
1004
- return;
1601
+ const status = await getHubStatus(this.store, this.ctx.config);
1602
+ const output = { ...base, connection: { ...base.connection, ...status } } as any;
1603
+ if (status.user?.status === "pending") {
1604
+ output.connection.pendingApproval = true;
1005
1605
  }
1006
- const raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
1007
- const entries = raw?.plugins?.entries ?? {};
1008
- const pluginEntry = entries["memos-local-openclaw-plugin"]?.config
1009
- ?? entries["memos-local"]?.config
1010
- ?? entries["memos-lite-openclaw-plugin"]?.config
1011
- ?? entries["memos-lite"]?.config
1012
- ?? {};
1013
- const result: Record<string, unknown> = { ...pluginEntry };
1014
- const topEntry = entries["memos-local-openclaw-plugin"]
1015
- ?? entries["memos-local"]
1016
- ?? entries["memos-lite-openclaw-plugin"]
1017
- ?? entries["memos-lite"]
1018
- ?? {};
1019
- if (pluginEntry.viewerPort == null && topEntry.viewerPort) {
1020
- result.viewerPort = topEntry.viewerPort;
1606
+ if (status.user?.status === "rejected") {
1607
+ output.connection.rejected = true;
1021
1608
  }
1022
- this.jsonResponse(res, result);
1023
- } catch (e) {
1024
- this.log.warn(`serveConfig error: ${e}`);
1025
- this.jsonResponse(res, {});
1609
+ if (status.connected && status.hubUrl) {
1610
+ try {
1611
+ const info = await fetch(`${status.hubUrl}/api/v1/hub/info`).then((r) => (r.ok ? r.json() : null)).catch(() => null) as any;
1612
+ output.connection.teamName = info?.teamName ?? null;
1613
+ output.connection.apiVersion = info?.apiVersion ?? null;
1614
+ } catch {}
1615
+ } else if (status.hubUrl) {
1616
+ try {
1617
+ const info = await fetch(`${status.hubUrl}/api/v1/hub/info`).then((r) => (r.ok ? r.json() : null)).catch(() => null) as any;
1618
+ output.connection.teamName = info?.teamName ?? null;
1619
+ } catch {}
1620
+ }
1621
+ output.admin.canManageUsers = status.connected && status.user?.role === "admin";
1622
+ output.admin.rejectSupported = output.admin.canManageUsers;
1623
+ this.jsonResponse(res, output);
1624
+ } catch (err) {
1625
+ this.jsonResponse(res, { ...base, error: String(err) });
1026
1626
  }
1027
1627
  }
1028
1628
 
1029
- private handleSaveConfig(req: http.IncomingMessage, res: http.ServerResponse): void {
1030
- this.readBody(req, (body) => {
1629
+ private async serveSharingPendingUsers(res: http.ServerResponse): Promise<void> {
1630
+ if (!this.ctx) return this.jsonResponse(res, { users: [], error: "sharing_unavailable" });
1631
+ try {
1632
+ const hub = this.resolveHubConnection();
1633
+ if (!hub) return this.jsonResponse(res, { users: [], error: "not_configured" });
1634
+ const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/pending-users", { method: "GET" }) as any;
1635
+ this.jsonResponse(res, { users: Array.isArray(data?.users) ? data.users : [] });
1636
+ } catch (err) {
1637
+ this.jsonResponse(res, { users: [], error: String(err) });
1638
+ }
1639
+ }
1640
+
1641
+ private handleSharingApproveUser(req: http.IncomingMessage, res: http.ServerResponse): void {
1642
+ this.readBody(req, async (body) => {
1643
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1031
1644
  try {
1032
- const newCfg = JSON.parse(body);
1033
- const cfgPath = this.getOpenClawConfigPath();
1034
- let raw: Record<string, unknown> = {};
1035
- if (fs.existsSync(cfgPath)) {
1036
- raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
1037
- }
1645
+ const parsed = JSON.parse(body || "{}");
1646
+ const hub = this.resolveHubConnection();
1647
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1648
+ const result = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/approve-user", {
1649
+ method: "POST",
1650
+ body: JSON.stringify({ userId: parsed.userId, username: parsed.username }),
1651
+ });
1652
+ this.jsonResponse(res, { ok: true, result });
1653
+ } catch (err) {
1654
+ this.jsonResponse(res, { ok: false, error: String(err) });
1655
+ }
1656
+ });
1657
+ }
1038
1658
 
1039
- if (!raw.plugins) raw.plugins = {};
1040
- const plugins = raw.plugins as Record<string, unknown>;
1041
- if (!plugins.entries) plugins.entries = {};
1042
- const entries = plugins.entries as Record<string, unknown>;
1043
- const entryKey = entries["memos-local-openclaw-plugin"] ? "memos-local-openclaw-plugin"
1044
- : entries["memos-local"] ? "memos-local"
1045
- : entries["memos-lite-openclaw-plugin"] ? "memos-lite-openclaw-plugin"
1046
- : entries["memos-lite"] ? "memos-lite"
1047
- : "memos-local-openclaw-plugin";
1048
- if (!entries[entryKey]) entries[entryKey] = { enabled: true };
1049
- const entry = entries[entryKey] as Record<string, unknown>;
1050
- if (!entry.config) entry.config = {};
1051
- const config = entry.config as Record<string, unknown>;
1659
+ private handleSharingRejectUser(req: http.IncomingMessage, res: http.ServerResponse): void {
1660
+ this.readBody(req, async (body) => {
1661
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1662
+ try {
1663
+ const parsed = JSON.parse(body || "{}");
1664
+ const hub = this.resolveHubConnection();
1665
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1666
+ const result = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/reject-user", {
1667
+ method: "POST",
1668
+ body: JSON.stringify({ userId: parsed.userId }),
1669
+ });
1670
+ this.jsonResponse(res, { ok: true, result });
1671
+ } catch (err) {
1672
+ this.jsonResponse(res, { ok: false, error: String(err) });
1673
+ }
1674
+ });
1675
+ }
1052
1676
 
1053
- if (newCfg.embedding) config.embedding = newCfg.embedding;
1054
- if (newCfg.summarizer) config.summarizer = newCfg.summarizer;
1677
+ private handleSharingChangeRole(req: http.IncomingMessage, res: http.ServerResponse): void {
1678
+ this.readBody(req, async (body) => {
1679
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1680
+ try {
1681
+ const parsed = JSON.parse(body || "{}");
1682
+ const hub = this.resolveHubConnection();
1683
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1684
+ const result = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/change-role", {
1685
+ method: "POST",
1686
+ body: JSON.stringify({ userId: parsed.userId, role: parsed.role }),
1687
+ });
1688
+ this.jsonResponse(res, { ok: true, result });
1689
+ } catch (err) {
1690
+ this.jsonResponse(res, { ok: false, error: String(err) });
1691
+ }
1692
+ });
1693
+ }
1694
+
1695
+ private handleSharingRemoveUser(req: http.IncomingMessage, res: http.ServerResponse): void {
1696
+ this.readBody(req, async (body) => {
1697
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1698
+ try {
1699
+ const parsed = JSON.parse(body || "{}");
1700
+ const hub = this.resolveHubConnection();
1701
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1702
+ const result = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/remove-user", {
1703
+ method: "POST",
1704
+ body: JSON.stringify({ userId: parsed.userId, cleanResources: parsed.cleanResources === true }),
1705
+ });
1706
+ this.jsonResponse(res, { ok: true, result });
1707
+ } catch (err) {
1708
+ this.jsonResponse(res, { ok: false, error: String(err) });
1709
+ }
1710
+ });
1711
+ }
1712
+
1713
+ private handleAdminRenameUser(req: http.IncomingMessage, res: http.ServerResponse): void {
1714
+ this.readBody(req, async (body) => {
1715
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1716
+ try {
1717
+ const parsed = JSON.parse(body || "{}");
1718
+ const hub = this.resolveHubConnection();
1719
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1720
+ const result = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/rename-user", {
1721
+ method: "POST",
1722
+ body: JSON.stringify({ userId: parsed.userId, username: parsed.username }),
1723
+ });
1724
+ this.jsonResponse(res, { ok: true, result });
1725
+ } catch (err) {
1726
+ this.jsonResponse(res, { ok: false, error: String(err) });
1727
+ }
1728
+ });
1729
+ }
1730
+
1731
+ private handleRetryJoin(req: http.IncomingMessage, res: http.ServerResponse): void {
1732
+ this.readBody(req, async (_body) => {
1733
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1734
+ const sharing = this.ctx.config.sharing;
1735
+ if (!sharing?.enabled || sharing.role !== "client") {
1736
+ return this.jsonResponse(res, { ok: false, error: "not_in_client_mode" });
1737
+ }
1738
+ const hubAddress = sharing.client?.hubAddress ?? "";
1739
+ const teamToken = sharing.client?.teamToken ?? "";
1740
+ if (!hubAddress || !teamToken) {
1741
+ return this.jsonResponse(res, { ok: false, error: "missing_hub_address_or_team_token" });
1742
+ }
1743
+ try {
1744
+ const hubUrl = normalizeHubUrl(hubAddress);
1745
+ const os = await import("os");
1746
+ const nickname = sharing.client?.nickname;
1747
+ const username = nickname || os.userInfo().username || "user";
1748
+ const hostname = os.hostname() || "unknown";
1749
+ const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
1750
+ method: "POST",
1751
+ body: JSON.stringify({ teamToken, username, deviceName: hostname }),
1752
+ }) as any;
1753
+ this.store.setClientHubConnection({
1754
+ hubUrl,
1755
+ userId: String(result.userId || ""),
1756
+ username,
1757
+ userToken: result.userToken || "",
1758
+ role: "member",
1759
+ connectedAt: Date.now(),
1760
+ });
1761
+ this.jsonResponse(res, { ok: true, status: result.status || "pending" });
1762
+ } catch (err) {
1763
+ this.jsonResponse(res, { ok: false, error: String(err) });
1764
+ }
1765
+ });
1766
+ }
1767
+
1768
+ private async serveSharingMemoryList(res: http.ServerResponse, url: URL): Promise<void> {
1769
+ if (!this.ctx) return this.jsonResponse(res, { memories: [], error: "sharing_unavailable" });
1770
+ try {
1771
+ const limit = Number(url.searchParams.get("limit") || 40);
1772
+ const hub = this.resolveHubConnection();
1773
+ let data: any;
1774
+ if (hub) {
1775
+ data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/memories?limit=${limit}`);
1776
+ } else {
1777
+ data = await hubListMemories(this.store, this.ctx, { limit });
1778
+ }
1779
+ this.jsonResponse(res, { memories: Array.isArray(data?.memories) ? data.memories : [] });
1780
+ } catch (err) {
1781
+ this.jsonResponse(res, { memories: [], error: String(err) });
1782
+ }
1783
+ }
1784
+
1785
+ private async serveSharingTaskList(res: http.ServerResponse, url: URL): Promise<void> {
1786
+ if (!this.ctx) return this.jsonResponse(res, { tasks: [], error: "sharing_unavailable" });
1787
+ try {
1788
+ const limit = Number(url.searchParams.get("limit") || 40);
1789
+ const hub = this.resolveHubConnection();
1790
+ let data: any;
1791
+ if (hub) {
1792
+ data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/tasks?limit=${limit}`);
1793
+ } else {
1794
+ data = await hubListTasks(this.store, this.ctx, { limit });
1795
+ }
1796
+ this.jsonResponse(res, { tasks: Array.isArray(data?.tasks) ? data.tasks : [] });
1797
+ } catch (err) {
1798
+ this.jsonResponse(res, { tasks: [], error: String(err) });
1799
+ }
1800
+ }
1801
+
1802
+ private async serveSharingSkillList(res: http.ServerResponse, url: URL): Promise<void> {
1803
+ if (!this.ctx) return this.jsonResponse(res, { skills: [], error: "sharing_unavailable" });
1804
+ try {
1805
+ const limit = Number(url.searchParams.get("limit") || 40);
1806
+ const hub = this.resolveHubConnection();
1807
+ let data: any;
1808
+ if (hub) {
1809
+ data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/skills/list?limit=${limit}`);
1810
+ } else {
1811
+ data = await hubListSkills(this.store, this.ctx, { limit });
1812
+ }
1813
+ this.jsonResponse(res, { skills: Array.isArray(data?.skills) ? data.skills : [] });
1814
+ } catch (err) {
1815
+ this.jsonResponse(res, { skills: [], error: String(err) });
1816
+ }
1817
+ }
1818
+
1819
+ private handleSharingMemorySearch(req: http.IncomingMessage, res: http.ServerResponse): void {
1820
+ this.readBody(req, async (body) => {
1821
+ if (!this.ctx) return this.jsonResponse(res, { local: { hits: [], meta: {} }, hub: { hits: [], meta: { totalCandidates: 0, searchedGroups: [], includedPublic: false } }, error: "sharing_unavailable" });
1822
+ const emptyHub = { hits: [], meta: { totalCandidates: 0, searchedGroups: [], includedPublic: false } };
1823
+ try {
1824
+ const parsed = JSON.parse(body || "{}");
1825
+ const query = String(parsed.query || "");
1826
+ const role = typeof parsed.role === "string" ? parsed.role : undefined;
1827
+ const maxResults = typeof parsed.maxResults === "number" ? parsed.maxResults : 10;
1828
+ const scope = parsed.scope === "group" || parsed.scope === "all" || parsed.scope === "hub" ? (parsed.scope === "hub" ? "all" : parsed.scope) : "local";
1829
+ const local = this.searchLocalViewerMemories(query, { role, maxResults });
1830
+ if (scope === "local") {
1831
+ return this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub: emptyHub });
1832
+ }
1833
+ try {
1834
+ const conn = this.resolveHubConnection();
1835
+ let hub: any;
1836
+ if (conn) {
1837
+ hub = await hubRequestJson(conn.hubUrl, conn.userToken, "/api/v1/hub/search", {
1838
+ method: "POST", body: JSON.stringify({ query, maxResults, scope }),
1839
+ });
1840
+ } else {
1841
+ hub = await hubSearchMemories(this.store, this.ctx!, { query, maxResults, scope });
1842
+ }
1843
+ this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub });
1844
+ } catch (err) {
1845
+ this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub: emptyHub, error: String(err) });
1846
+ }
1847
+ } catch (err) {
1848
+ this.jsonResponse(res, { local: { hits: [], meta: {} }, hub: emptyHub, error: String(err) });
1849
+ }
1850
+ });
1851
+ }
1852
+
1853
+ private handleSharingMemoryDetail(req: http.IncomingMessage, res: http.ServerResponse): void {
1854
+ this.readBody(req, async (body) => {
1855
+ if (!this.ctx) return this.jsonResponse(res, { error: "sharing_unavailable" });
1856
+ try {
1857
+ const parsed = JSON.parse(body || "{}");
1858
+ const detail = await hubGetMemoryDetail(this.store, this.ctx, { remoteHitId: String(parsed.remoteHitId || "") });
1859
+ this.jsonResponse(res, detail);
1860
+ } catch (err) {
1861
+ this.jsonResponse(res, { error: String(err) });
1862
+ }
1863
+ });
1864
+ }
1865
+
1866
+ private async serveSharingSkillSearch(res: http.ServerResponse, url: URL): Promise<void> {
1867
+ if (!this.ctx) return this.jsonResponse(res, { local: { hits: [] }, hub: { hits: [] }, error: "sharing_unavailable" });
1868
+ try {
1869
+ const query = String(url.searchParams.get("query") || "");
1870
+ const scope = url.searchParams.get("scope") === "group" || url.searchParams.get("scope") === "all" ? url.searchParams.get("scope")! : "local";
1871
+ const recall = new RecallEngine(this.store, this.embedder, this.ctx);
1872
+ const localHits = await recall.searchSkills(query, "mix" as any, "agent:main");
1873
+ if (scope === "local") {
1874
+ return this.jsonResponse(res, { local: { hits: localHits }, hub: { hits: [] } });
1875
+ }
1876
+ try {
1877
+ const hub = await hubSearchSkills(this.store, this.ctx, { query, maxResults: Number(url.searchParams.get("maxResults") || 20) });
1878
+ this.jsonResponse(res, { local: { hits: localHits }, hub });
1879
+ } catch (err) {
1880
+ this.jsonResponse(res, { local: { hits: localHits }, hub: { hits: [] }, error: String(err) });
1881
+ }
1882
+ } catch (err) {
1883
+ this.jsonResponse(res, { local: { hits: [] }, hub: { hits: [] }, error: String(err) });
1884
+ }
1885
+ }
1886
+
1887
+ private searchLocalViewerMemories(query: string, options?: { role?: string; maxResults?: number }): { hits: any[]; meta: Record<string, unknown> } {
1888
+ const db = (this.store as any).db;
1889
+ const role = options?.role;
1890
+ const maxResults = options?.maxResults ?? 10;
1891
+ const params: any[] = [];
1892
+ let rows: any[] = [];
1893
+ try {
1894
+ let sql = "SELECT c.* FROM chunks_fts f JOIN chunks c ON f.rowid = c.rowid WHERE chunks_fts MATCH ?";
1895
+ params.push(query);
1896
+ if (role) {
1897
+ sql += " AND c.role = ?";
1898
+ params.push(role);
1899
+ }
1900
+ sql += " ORDER BY rank LIMIT ?";
1901
+ params.push(maxResults);
1902
+ rows = db.prepare(sql).all(...params);
1903
+ } catch {
1904
+ const likeParams: any[] = [`%${query}%`, `%${query}%`];
1905
+ let sql = "SELECT * FROM chunks WHERE (content LIKE ? OR summary LIKE ?)";
1906
+ if (role) {
1907
+ sql += " AND role = ?";
1908
+ likeParams.push(role);
1909
+ }
1910
+ sql += " ORDER BY created_at DESC LIMIT ?";
1911
+ likeParams.push(maxResults);
1912
+ rows = db.prepare(sql).all(...likeParams);
1913
+ }
1914
+ const hits = rows.map((row: any, idx: number) => ({
1915
+ id: row.id,
1916
+ summary: row.summary || row.content?.slice(0, 120) || "",
1917
+ excerpt: row.content || "",
1918
+ score: Math.max(0.3, 1 - idx * 0.1),
1919
+ role: row.role,
1920
+ ref: { sessionKey: row.session_key, chunkId: row.id, turnId: row.turn_id, seq: row.seq },
1921
+ taskId: row.task_id ?? null,
1922
+ skillId: row.skill_id ?? null,
1923
+ }));
1924
+ return { hits, meta: { total: hits.length, usedMaxResults: maxResults } };
1925
+ }
1926
+
1927
+ private handleSharingTaskShare(req: http.IncomingMessage, res: http.ServerResponse): void {
1928
+ this.readBody(req, async (body) => {
1929
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1930
+ try {
1931
+ const parsed = JSON.parse(body || "{}");
1932
+ const taskId = String(parsed.taskId || "");
1933
+ const visibility = "public";
1934
+ const groupId: string | undefined = undefined;
1935
+ const task = this.store.getTask(taskId);
1936
+ if (!task) return this.jsonResponse(res, { ok: false, error: "task_not_found" });
1937
+ const chunks = this.store.getChunksByTask(taskId);
1938
+ if (chunks.length === 0) return this.jsonResponse(res, { ok: false, error: "no_chunks" });
1939
+ const hubClient = await this.resolveHubClientAware();
1940
+ const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/share", {
1941
+ method: "POST",
1942
+ body: JSON.stringify({
1943
+ task: {
1944
+ id: task.id,
1945
+ sourceTaskId: task.id,
1946
+ title: task.title,
1947
+ summary: task.summary,
1948
+ groupId: null,
1949
+ visibility,
1950
+ createdAt: task.startedAt ?? Date.now(),
1951
+ updatedAt: task.updatedAt ?? Date.now(),
1952
+ },
1953
+ chunks: chunks.map((chunk) => ({
1954
+ id: chunk.id,
1955
+ hubTaskId: task.id,
1956
+ sourceTaskId: task.id,
1957
+ sourceChunkId: chunk.id,
1958
+ role: chunk.role,
1959
+ content: chunk.content,
1960
+ summary: chunk.summary,
1961
+ kind: chunk.kind,
1962
+ createdAt: chunk.createdAt,
1963
+ })),
1964
+ }),
1965
+ });
1966
+ const hubUserId = hubClient.userId;
1967
+ if (hubUserId) {
1968
+ this.store.upsertHubTask({
1969
+ id: task.id,
1970
+ sourceTaskId: task.id,
1971
+ sourceUserId: hubUserId,
1972
+ title: task.title,
1973
+ summary: task.summary,
1974
+ groupId: null,
1975
+ visibility,
1976
+ createdAt: task.startedAt ?? Date.now(),
1977
+ updatedAt: task.updatedAt ?? Date.now(),
1978
+ });
1979
+ }
1980
+ this.jsonResponse(res, { ok: true, taskId, visibility, response });
1981
+ } catch (err) {
1982
+ this.jsonResponse(res, { ok: false, error: String(err) });
1983
+ }
1984
+ });
1985
+ }
1986
+
1987
+ private handleSharingTaskUnshare(req: http.IncomingMessage, res: http.ServerResponse): void {
1988
+ this.readBody(req, async (body) => {
1989
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1990
+ try {
1991
+ const parsed = JSON.parse(body || "{}");
1992
+ const taskId = String(parsed.taskId || "");
1993
+ const task = this.store.getTask(taskId);
1994
+ if (!task) return this.jsonResponse(res, { ok: false, error: "task_not_found" });
1995
+ const hubClient = await this.resolveHubClientAware();
1996
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/unshare", {
1997
+ method: "POST",
1998
+ body: JSON.stringify({ sourceTaskId: task.id }),
1999
+ });
2000
+ const hubUserId = hubClient.userId;
2001
+ if (hubUserId) this.store.deleteHubTaskBySource(hubUserId, task.id);
2002
+ this.jsonResponse(res, { ok: true, taskId });
2003
+ } catch (err) {
2004
+ this.jsonResponse(res, { ok: false, error: String(err) });
2005
+ }
2006
+ });
2007
+ }
2008
+
2009
+ private handleSharingMemoryShare(req: http.IncomingMessage, res: http.ServerResponse): void {
2010
+ this.readBody(req, async (body) => {
2011
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
2012
+ try {
2013
+ const parsed = JSON.parse(body || "{}");
2014
+ const chunkId = String(parsed.chunkId || "");
2015
+ const visibility = "public";
2016
+ const groupId: string | undefined = undefined;
2017
+ const db = (this.store as any).db;
2018
+ const chunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId) as any;
2019
+ if (!chunk) return this.jsonResponse(res, { ok: false, error: "memory_not_found" });
2020
+ const hubClient = await this.resolveHubClientAware();
2021
+ const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/share", {
2022
+ method: "POST",
2023
+ body: JSON.stringify({
2024
+ memory: {
2025
+ sourceChunkId: chunk.id,
2026
+ role: chunk.role,
2027
+ content: chunk.content,
2028
+ summary: chunk.summary,
2029
+ kind: chunk.kind,
2030
+ groupId: null,
2031
+ visibility,
2032
+ },
2033
+ }),
2034
+ });
2035
+ const hubUserId = hubClient.userId;
2036
+ if (hubUserId) {
2037
+ const now = Date.now();
2038
+ const existing = this.store.getHubMemoryBySource(hubUserId, chunk.id);
2039
+ this.store.upsertHubMemory({
2040
+ id: (response as any)?.memoryId ?? existing?.id ?? crypto.randomUUID(),
2041
+ sourceChunkId: chunk.id,
2042
+ sourceUserId: hubUserId,
2043
+ role: chunk.role,
2044
+ content: chunk.content,
2045
+ summary: chunk.summary ?? "",
2046
+ kind: chunk.kind,
2047
+ groupId: null,
2048
+ visibility,
2049
+ createdAt: existing?.createdAt ?? now,
2050
+ updatedAt: now,
2051
+ });
2052
+ }
2053
+ this.jsonResponse(res, { ok: true, chunkId, visibility, response });
2054
+ } catch (err) {
2055
+ this.jsonResponse(res, { ok: false, error: String(err) });
2056
+ }
2057
+ });
2058
+ }
2059
+
2060
+ private handleSharingMemoryUnshare(req: http.IncomingMessage, res: http.ServerResponse): void {
2061
+ this.readBody(req, async (body) => {
2062
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
2063
+ try {
2064
+ const parsed = JSON.parse(body || "{}");
2065
+ const chunkId = String(parsed.chunkId || "");
2066
+ const hubClient = await this.resolveHubClientAware();
2067
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/unshare", {
2068
+ method: "POST",
2069
+ body: JSON.stringify({ sourceChunkId: chunkId }),
2070
+ });
2071
+ const hubUserId = hubClient.userId;
2072
+ if (hubUserId) this.store.deleteHubMemoryBySource(hubUserId, chunkId);
2073
+ this.jsonResponse(res, { ok: true, chunkId });
2074
+ } catch (err) {
2075
+ this.jsonResponse(res, { ok: false, error: String(err) });
2076
+ }
2077
+ });
2078
+ }
2079
+
2080
+ private handleSharingSkillPull(req: http.IncomingMessage, res: http.ServerResponse): void {
2081
+ this.readBody(req, async (body) => {
2082
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
2083
+ try {
2084
+ const parsed = JSON.parse(body || "{}");
2085
+ const skillId = String(parsed.skillId || "");
2086
+ const payload = await fetchHubSkillBundle(this.store, this.ctx, { skillId });
2087
+ const restored = restoreSkillBundleFromHub(this.store, this.ctx, payload);
2088
+ this.jsonResponse(res, { ok: true, pulled: true, hubSkillId: skillId, ...restored });
2089
+ } catch (err) {
2090
+ this.jsonResponse(res, { ok: false, error: String(err) });
2091
+ }
2092
+ });
2093
+ }
2094
+
2095
+ private handleSharingSkillShare(req: http.IncomingMessage, res: http.ServerResponse): void {
2096
+ this.readBody(req, async (body) => {
2097
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
2098
+ try {
2099
+ const parsed = JSON.parse(body || "{}");
2100
+ const skillId = String(parsed.skillId || "");
2101
+ const visibility = "public";
2102
+ const groupId: string | null = null;
2103
+ const skill = this.store.getSkill(skillId);
2104
+ if (!skill) return this.jsonResponse(res, { ok: false, error: "skill_not_found" });
2105
+ const bundle = buildSkillBundleForHub(this.store, skillId);
2106
+ const hubClient = await this.resolveHubClientAware();
2107
+ const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/publish", {
2108
+ method: "POST",
2109
+ body: JSON.stringify({
2110
+ visibility,
2111
+ groupId: null,
2112
+ metadata: bundle.metadata,
2113
+ bundle: bundle.bundle,
2114
+ }),
2115
+ });
2116
+ const hubUserId = hubClient.userId;
2117
+ if (hubUserId) {
2118
+ const existing = this.store.getHubSkillBySource(hubUserId, skillId);
2119
+ this.store.upsertHubSkill({
2120
+ id: (response as any)?.skillId ?? existing?.id ?? crypto.randomUUID(),
2121
+ sourceSkillId: skillId,
2122
+ sourceUserId: hubUserId,
2123
+ name: skill.name,
2124
+ description: skill.description,
2125
+ version: skill.version,
2126
+ groupId: null,
2127
+ visibility,
2128
+ bundle: JSON.stringify(bundle.bundle),
2129
+ qualityScore: skill.qualityScore,
2130
+ createdAt: existing?.createdAt ?? Date.now(),
2131
+ updatedAt: Date.now(),
2132
+ });
2133
+ }
2134
+ this.jsonResponse(res, { ok: true, skillId, visibility, response });
2135
+ } catch (err) {
2136
+ this.jsonResponse(res, { ok: false, error: String(err) });
2137
+ }
2138
+ });
2139
+ }
2140
+
2141
+ private handleSharingSkillUnshare(req: http.IncomingMessage, res: http.ServerResponse): void {
2142
+ this.readBody(req, async (body) => {
2143
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
2144
+ try {
2145
+ const parsed = JSON.parse(body || "{}");
2146
+ const skillId = String(parsed.skillId || "");
2147
+ const skill = this.store.getSkill(skillId);
2148
+ if (!skill) return this.jsonResponse(res, { ok: false, error: "skill_not_found" });
2149
+ const hubClient = await this.resolveHubClientAware();
2150
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/unpublish", {
2151
+ method: "POST",
2152
+ body: JSON.stringify({ sourceSkillId: skill.id }),
2153
+ });
2154
+ const hubUserId = hubClient.userId;
2155
+ if (hubUserId) this.store.deleteHubSkillBySource(hubUserId, skill.id);
2156
+ this.jsonResponse(res, { ok: true, skillId });
2157
+ } catch (err) {
2158
+ this.jsonResponse(res, { ok: false, error: String(err) });
2159
+ }
2160
+ });
2161
+ }
2162
+
2163
+ private resolveHubConnection(): { hubUrl: string; userToken: string } | null {
2164
+ if (!this.ctx) return null;
2165
+
2166
+ // Hub 模式:连接自己,用 bootstrap admin token
2167
+ const sharing = this.ctx.config.sharing;
2168
+ if (sharing?.role === "hub") {
2169
+ const hubPort = sharing.hub?.port ?? 18800;
2170
+ const hubUrl = `http://127.0.0.1:${hubPort}`;
2171
+ try {
2172
+ const authPath = path.join(this.dataDir, "hub-auth.json");
2173
+ const authData = JSON.parse(fs.readFileSync(authPath, "utf8"));
2174
+ const adminToken = authData?.bootstrapAdminToken;
2175
+ if (adminToken) return { hubUrl, userToken: adminToken };
2176
+ } catch {
2177
+ // hub-auth.json 不存在或读取失败,fall through
2178
+ }
2179
+ }
2180
+
2181
+ // Client 模式:用配置的 hubAddress + userToken
2182
+ const conn = this.store.getClientHubConnection();
2183
+ const hubUrl = conn?.hubUrl || this.ctx.config.sharing?.client?.hubAddress || "";
2184
+ const userToken = conn?.userToken || this.ctx.config.sharing?.client?.userToken || "";
2185
+ if (!hubUrl || !userToken) return null;
2186
+ return { hubUrl: normalizeHubUrl(hubUrl), userToken };
2187
+ }
2188
+
2189
+ /** resolveHubClient 的 viewer 版本:hub 模式下使用 bootstrap admin 身份 */
2190
+ private async resolveHubClientAware(): Promise<ResolvedHubClient> {
2191
+ if (!this.ctx) throw new Error("sharing_unavailable");
2192
+ const sharing = this.ctx.config.sharing;
2193
+ if (sharing?.role === "hub") {
2194
+ const hub = this.resolveHubConnection();
2195
+ if (hub) {
2196
+ const me = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/me", { method: "GET" }) as any;
2197
+ return {
2198
+ hubUrl: hub.hubUrl,
2199
+ userToken: hub.userToken,
2200
+ userId: String(me.id),
2201
+ username: String(me.username ?? "hub-admin"),
2202
+ role: String(me.role ?? "admin"),
2203
+ };
2204
+ }
2205
+ }
2206
+ return resolveHubClient(this.store, this.ctx);
2207
+ }
2208
+
2209
+ private async serveSharingUsers(res: http.ServerResponse): Promise<void> {
2210
+ const hub = this.resolveHubConnection();
2211
+ if (!hub) return this.jsonResponse(res, { users: [], error: "not_configured" });
2212
+ try {
2213
+ const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/users", { method: "GET" }) as any;
2214
+ this.jsonResponse(res, { users: Array.isArray(data?.users) ? data.users : [] });
2215
+ } catch (err) {
2216
+ this.jsonResponse(res, { users: [], error: String(err) });
2217
+ }
2218
+ }
2219
+
2220
+ // ─── Admin management endpoints (Hub-side data) ───
2221
+
2222
+ private async serveAdminSharedTasks(res: http.ServerResponse): Promise<void> {
2223
+ const hub = this.resolveHubConnection();
2224
+ if (!hub) return this.jsonResponse(res, { tasks: [], error: "not_configured" });
2225
+ try {
2226
+ const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/shared-tasks", { method: "GET" }) as any;
2227
+ const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
2228
+ for (const tk of tasks) {
2229
+ if (!tk.summary && tk.sourceTaskId) {
2230
+ const local = this.store.getTask(tk.sourceTaskId);
2231
+ if (local) { tk.summary = local.summary; tk.title = tk.title || local.title; }
2232
+ }
2233
+ }
2234
+ this.jsonResponse(res, { tasks });
2235
+ } catch (err) {
2236
+ this.jsonResponse(res, { tasks: [], error: String(err) });
2237
+ }
2238
+ }
2239
+
2240
+ private async handleAdminDeleteTask(res: http.ServerResponse, p: string): Promise<void> {
2241
+ const hub = this.resolveHubConnection();
2242
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
2243
+ const taskId = decodeURIComponent(p.replace("/api/admin/shared-tasks/", ""));
2244
+ try {
2245
+ await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/admin/shared-tasks/${encodeURIComponent(taskId)}`, { method: "DELETE" });
2246
+ this.jsonResponse(res, { ok: true });
2247
+ } catch (err) {
2248
+ this.jsonResponse(res, { ok: false, error: String(err) });
2249
+ }
2250
+ }
2251
+
2252
+ private async serveHubTaskDetail(res: http.ServerResponse, p: string): Promise<void> {
2253
+ const hub = this.resolveHubConnection();
2254
+ if (!hub) return this.jsonResponse(res, { error: "not_configured" }, 500);
2255
+ const m = p.match(/^\/api\/admin\/shared-tasks\/([^/]+)\/detail$/);
2256
+ if (!m) return this.jsonResponse(res, { error: "bad_request" }, 400);
2257
+ const taskId = decodeURIComponent(m[1]);
2258
+ try {
2259
+ const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-tasks/${encodeURIComponent(taskId)}/detail`, { method: "GET" }) as any;
2260
+ this.jsonResponse(res, data);
2261
+ } catch (err) {
2262
+ this.jsonResponse(res, { error: String(err) }, 500);
2263
+ }
2264
+ }
2265
+
2266
+ private async serveHubSkillDetail(res: http.ServerResponse, p: string): Promise<void> {
2267
+ const hub = this.resolveHubConnection();
2268
+ if (!hub) return this.jsonResponse(res, { error: "not_configured" }, 500);
2269
+ const m = p.match(/^\/api\/admin\/shared-skills\/([^/]+)\/detail$/);
2270
+ if (!m) return this.jsonResponse(res, { error: "bad_request" }, 400);
2271
+ const skillId = decodeURIComponent(m[1]);
2272
+ try {
2273
+ const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-skills/${encodeURIComponent(skillId)}/detail`, { method: "GET" }) as any;
2274
+ this.jsonResponse(res, data);
2275
+ } catch (err) {
2276
+ this.jsonResponse(res, { error: String(err) }, 500);
2277
+ }
2278
+ }
2279
+
2280
+ private async serveAdminSharedSkills(res: http.ServerResponse): Promise<void> {
2281
+ const hub = this.resolveHubConnection();
2282
+ if (!hub) return this.jsonResponse(res, { skills: [], error: "not_configured" });
2283
+ try {
2284
+ const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/shared-skills", { method: "GET" }) as any;
2285
+ const skills = Array.isArray(data?.skills) ? data.skills : [];
2286
+ for (const sk of skills) {
2287
+ if (!sk.description && sk.sourceSkillId) {
2288
+ const local = this.store.getSkill(sk.sourceSkillId);
2289
+ if (local) { sk.description = sk.description || local.description; sk.name = sk.name || local.name; }
2290
+ }
2291
+ }
2292
+ this.jsonResponse(res, { skills });
2293
+ } catch (err) {
2294
+ this.jsonResponse(res, { skills: [], error: String(err) });
2295
+ }
2296
+ }
2297
+
2298
+ private async handleAdminDeleteSkill(res: http.ServerResponse, p: string): Promise<void> {
2299
+ const hub = this.resolveHubConnection();
2300
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
2301
+ const skillId = decodeURIComponent(p.replace("/api/admin/shared-skills/", ""));
2302
+ try {
2303
+ await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/admin/shared-skills/${encodeURIComponent(skillId)}`, { method: "DELETE" });
2304
+ this.jsonResponse(res, { ok: true });
2305
+ } catch (err) {
2306
+ this.jsonResponse(res, { ok: false, error: String(err) });
2307
+ }
2308
+ }
2309
+
2310
+ private async serveAdminSharedMemories(res: http.ServerResponse): Promise<void> {
2311
+ const hub = this.resolveHubConnection();
2312
+ if (!hub) return this.jsonResponse(res, { memories: [], error: "not_configured" });
2313
+ try {
2314
+ const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/shared-memories", { method: "GET" }) as any;
2315
+ const memories = Array.isArray(data?.memories) ? data.memories : [];
2316
+ for (const m of memories) {
2317
+ if (!m.content && m.sourceChunkId) {
2318
+ const local = this.store.getChunk(m.sourceChunkId);
2319
+ if (local) { m.content = local.content; if (!m.summary && local.summary) m.summary = local.summary; }
2320
+ }
2321
+ }
2322
+ this.jsonResponse(res, { memories });
2323
+ } catch (err) {
2324
+ this.jsonResponse(res, { memories: [], error: String(err) });
2325
+ }
2326
+ }
2327
+
2328
+ private async handleAdminDeleteMemory(res: http.ServerResponse, p: string): Promise<void> {
2329
+ const hub = this.resolveHubConnection();
2330
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
2331
+ const memoryId = decodeURIComponent(p.replace("/api/admin/shared-memories/", ""));
2332
+ try {
2333
+ await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/admin/shared-memories/${encodeURIComponent(memoryId)}`, { method: "DELETE" });
2334
+ this.jsonResponse(res, { ok: true });
2335
+ } catch (err) {
2336
+ this.jsonResponse(res, { ok: false, error: String(err) });
2337
+ }
2338
+ }
2339
+
2340
+ private async serveSharingNotifications(res: http.ServerResponse, url: URL): Promise<void> {
2341
+ const hub = this.resolveHubConnection();
2342
+ if (!hub) return this.jsonResponse(res, { notifications: [], unreadCount: 0 });
2343
+ try {
2344
+ const unread = url.searchParams.get("unread") === "1" ? "?unread=1" : "";
2345
+ const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/notifications${unread}`) as any;
2346
+ this.jsonResponse(res, data);
2347
+ } catch {
2348
+ this.jsonResponse(res, { notifications: [], unreadCount: 0 });
2349
+ }
2350
+ }
2351
+
2352
+ private handleSharingNotificationsRead(req: http.IncomingMessage, res: http.ServerResponse): void {
2353
+ const hub = this.resolveHubConnection();
2354
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
2355
+ this.readBody(req, async (raw) => {
2356
+ try {
2357
+ const body = JSON.parse(raw || "{}");
2358
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/read", { method: "POST", body: JSON.stringify(body) });
2359
+ this.jsonResponse(res, { ok: true });
2360
+ try {
2361
+ const data = (await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")) as any;
2362
+ const count = data?.unreadCount ?? 0;
2363
+ this.lastKnownNotifCount = count;
2364
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2365
+ } catch { /* best effort */ }
2366
+ } catch (err) {
2367
+ this.jsonResponse(res, { ok: false, error: String(err) });
2368
+ }
2369
+ });
2370
+ }
2371
+
2372
+ private handleSharingNotificationsClear(req: http.IncomingMessage, res: http.ServerResponse): void {
2373
+ const hub = this.resolveHubConnection();
2374
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
2375
+ this.readBody(req, async () => {
2376
+ try {
2377
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/clear", { method: "POST", body: "{}" });
2378
+ this.jsonResponse(res, { ok: true });
2379
+ this.broadcastNotifSSE({ type: "cleared", unreadCount: 0 });
2380
+ } catch (err) {
2381
+ this.jsonResponse(res, { ok: false, error: String(err) });
2382
+ }
2383
+ });
2384
+ }
2385
+
2386
+ private handleNotifSSE(req: http.IncomingMessage, res: http.ServerResponse): void {
2387
+ res.writeHead(200, {
2388
+ "Content-Type": "text/event-stream",
2389
+ "Cache-Control": "no-cache",
2390
+ Connection: "keep-alive",
2391
+ "Access-Control-Allow-Origin": "*",
2392
+ });
2393
+ res.write("data: {\"type\":\"connected\"}\n\n");
2394
+ this.notifSSEClients.push(res);
2395
+ if (!this.notifPollTimer) this.startNotifPoll();
2396
+ req.on("close", () => {
2397
+ this.notifSSEClients = this.notifSSEClients.filter((c) => c !== res);
2398
+ if (this.notifSSEClients.length === 0) this.stopNotifPoll();
2399
+ });
2400
+ }
2401
+
2402
+ private broadcastNotifSSE(data: Record<string, unknown>): void {
2403
+ const msg = `data: ${JSON.stringify(data)}\n\n`;
2404
+ this.notifSSEClients = this.notifSSEClients.filter((c) => {
2405
+ try { c.write(msg); return true; } catch { return false; }
2406
+ });
2407
+ }
2408
+
2409
+ private startNotifPoll(): void {
2410
+ this.stopNotifPoll();
2411
+ const tick = async () => {
2412
+ const hub = this.resolveHubConnection();
2413
+ if (!hub) return;
2414
+ try {
2415
+ const data = (await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")) as any;
2416
+ const count = data?.unreadCount ?? 0;
2417
+ if (count !== this.lastKnownNotifCount) {
2418
+ this.lastKnownNotifCount = count;
2419
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2420
+ }
2421
+ } catch { /* ignore */ }
2422
+ };
2423
+ tick();
2424
+ this.notifPollTimer = setInterval(tick, 3000);
2425
+ }
2426
+
2427
+ private stopNotifPoll(): void {
2428
+ if (this.notifPollTimer) { clearInterval(this.notifPollTimer); this.notifPollTimer = undefined; }
2429
+ }
2430
+
2431
+ private startHubHeartbeat(): void {
2432
+ this.stopHubHeartbeat();
2433
+ const sendHeartbeat = async () => {
2434
+ try {
2435
+ const hub = this.resolveHubConnection();
2436
+ if (!hub) {
2437
+ const persisted = this.store.getClientHubConnection();
2438
+ if (persisted?.hubUrl && persisted?.userToken) {
2439
+ await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
2440
+ }
2441
+ return;
2442
+ }
2443
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
2444
+ } catch { /* best-effort */ }
2445
+ };
2446
+ sendHeartbeat();
2447
+ this.hubHeartbeatTimer = setInterval(sendHeartbeat, ViewerServer.HUB_HEARTBEAT_INTERVAL_MS);
2448
+ }
2449
+
2450
+ private stopHubHeartbeat(): void {
2451
+ if (this.hubHeartbeatTimer) { clearInterval(this.hubHeartbeatTimer); this.hubHeartbeatTimer = undefined; }
2452
+ }
2453
+
2454
+ private getLocalIPs(): string[] {
2455
+ const nets = os.networkInterfaces();
2456
+ const ips: string[] = [];
2457
+ for (const name of Object.keys(nets)) {
2458
+ for (const net of nets[name] ?? []) {
2459
+ if (net.family === "IPv4" && !net.internal) {
2460
+ ips.push(net.address);
2461
+ }
2462
+ }
2463
+ }
2464
+ return ips;
2465
+ }
2466
+
2467
+ private serveLocalIPs(res: http.ServerResponse): void {
2468
+ const ips = this.getLocalIPs();
2469
+ res.writeHead(200, { "Content-Type": "application/json" });
2470
+ res.end(JSON.stringify({ ips }));
2471
+ }
2472
+
2473
+ private serveConfig(res: http.ServerResponse): void {
2474
+ try {
2475
+ const cfgPath = this.getOpenClawConfigPath();
2476
+ if (!fs.existsSync(cfgPath)) {
2477
+ this.jsonResponse(res, {});
2478
+ return;
2479
+ }
2480
+ const raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
2481
+ const entries = raw?.plugins?.entries ?? {};
2482
+ const pluginEntry = entries["memos-local-openclaw-plugin"]?.config
2483
+ ?? entries["memos-local"]?.config
2484
+ ?? entries["memos-lite-openclaw-plugin"]?.config
2485
+ ?? entries["memos-lite"]?.config
2486
+ ?? {};
2487
+ const result: Record<string, unknown> = { ...pluginEntry };
2488
+ const topEntry = entries["memos-local-openclaw-plugin"]
2489
+ ?? entries["memos-local"]
2490
+ ?? entries["memos-lite-openclaw-plugin"]
2491
+ ?? entries["memos-lite"]
2492
+ ?? {};
2493
+ if ((pluginEntry as any).viewerPort != null) {
2494
+ result.viewerPort = (pluginEntry as any).viewerPort;
2495
+ } else if (topEntry.viewerPort) {
2496
+ result.viewerPort = topEntry.viewerPort;
2497
+ }
2498
+ this.jsonResponse(res, result);
2499
+ } catch (e) {
2500
+ this.log.warn(`serveConfig error: ${e}`);
2501
+ this.jsonResponse(res, {});
2502
+ }
2503
+ }
2504
+
2505
+ private handleSaveConfig(req: http.IncomingMessage, res: http.ServerResponse): void {
2506
+ this.readBody(req, async (body) => {
2507
+ try {
2508
+ const newCfg = JSON.parse(body);
2509
+ const cfgPath = this.getOpenClawConfigPath();
2510
+ let raw: Record<string, unknown> = {};
2511
+ if (fs.existsSync(cfgPath)) {
2512
+ raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
2513
+ }
2514
+
2515
+ if (!raw.plugins) raw.plugins = {};
2516
+ const plugins = raw.plugins as Record<string, unknown>;
2517
+ if (!plugins.entries) plugins.entries = {};
2518
+ const entries = plugins.entries as Record<string, unknown>;
2519
+ const entryKey = entries["memos-local-openclaw-plugin"] ? "memos-local-openclaw-plugin"
2520
+ : entries["memos-local"] ? "memos-local"
2521
+ : entries["memos-lite-openclaw-plugin"] ? "memos-lite-openclaw-plugin"
2522
+ : entries["memos-lite"] ? "memos-lite"
2523
+ : "memos-local-openclaw-plugin";
2524
+ if (!entries[entryKey]) entries[entryKey] = { enabled: true };
2525
+ const entry = entries[entryKey] as Record<string, unknown>;
2526
+ if (!entry.config) entry.config = {};
2527
+ const config = entry.config as Record<string, unknown>;
2528
+
2529
+ const oldSharingRole = (config.sharing as Record<string, unknown>)?.role as string | undefined;
2530
+
2531
+ if (newCfg.embedding) config.embedding = newCfg.embedding;
2532
+ if (newCfg.summarizer) config.summarizer = newCfg.summarizer;
1055
2533
  if (newCfg.skillEvolution) config.skillEvolution = newCfg.skillEvolution;
1056
2534
  if (newCfg.viewerPort) config.viewerPort = newCfg.viewerPort;
1057
2535
  if (newCfg.telemetry !== undefined) config.telemetry = newCfg.telemetry;
2536
+ if (newCfg.sharing !== undefined) {
2537
+ const existing = (config.sharing as Record<string, unknown>) || {};
2538
+ const merged = { ...existing, ...newCfg.sharing };
2539
+ if (newCfg.sharing.capabilities && existing.capabilities) {
2540
+ merged.capabilities = { ...(existing.capabilities as Record<string, unknown>), ...newCfg.sharing.capabilities };
2541
+ }
2542
+ if (merged.role === "client" && merged.client) {
2543
+ const clientCfg = merged.client as Record<string, unknown>;
2544
+ const addr = String(clientCfg.hubAddress || "");
2545
+ if (addr) {
2546
+ const localIPs = this.getLocalIPs();
2547
+ localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2548
+ try {
2549
+ const u = new URL(addr.startsWith("http") ? addr : `http://${addr}`);
2550
+ if (localIPs.includes(u.hostname)) {
2551
+ res.writeHead(400, { "Content-Type": "application/json" });
2552
+ res.end(JSON.stringify({ error: "cannot_join_self" }));
2553
+ return;
2554
+ }
2555
+ } catch {}
2556
+ }
2557
+ }
2558
+
2559
+ // When switching away from client mode, notify Hub that we're leaving
2560
+ const newRole = merged.role as string | undefined;
2561
+ if (oldSharingRole === "client" && newRole !== "client") {
2562
+ this.notifyHubLeave();
2563
+ }
2564
+
2565
+ if (merged.role === "hub") {
2566
+ merged.client = { hubAddress: "", userToken: "", teamToken: "" };
2567
+ } else if (merged.role === "client") {
2568
+ merged.hub = { port: 18800, teamName: "", teamToken: "" };
2569
+ }
2570
+ config.sharing = merged;
2571
+ }
1058
2572
 
1059
2573
  fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
1060
2574
  fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2), "utf-8");
1061
2575
  this.log.info("Plugin config updated via Viewer");
2576
+ this.stopHubHeartbeat();
1062
2577
  this.jsonResponse(res, { ok: true });
1063
2578
  } catch (e) {
1064
2579
  this.log.warn(`handleSaveConfig error: ${e}`);
@@ -1068,6 +2583,106 @@ export class ViewerServer {
1068
2583
  });
1069
2584
  }
1070
2585
 
2586
+ private async notifyHubLeave(): Promise<void> {
2587
+ try {
2588
+ const hub = this.resolveHubConnection();
2589
+ if (hub) {
2590
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
2591
+ this.log.info("Notified Hub of voluntary leave");
2592
+ return;
2593
+ }
2594
+ const persisted = this.store.getClientHubConnection();
2595
+ if (persisted?.hubUrl && persisted?.userToken) {
2596
+ await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
2597
+ this.log.info("Notified Hub of voluntary leave (persisted connection)");
2598
+ }
2599
+ } catch (e) {
2600
+ this.log.warn(`Failed to notify Hub of leave: ${e}`);
2601
+ }
2602
+ }
2603
+
2604
+ private handleUpdateUsername(req: http.IncomingMessage, res: http.ServerResponse): void {
2605
+ this.readBody(req, async (body) => {
2606
+ if (!this.ctx) return this.jsonResponse(res, { error: "sharing_unavailable" });
2607
+ try {
2608
+ const { username } = JSON.parse(body || "{}");
2609
+ if (!username || typeof username !== "string" || username.trim().length < 2 || username.trim().length > 32) {
2610
+ return this.jsonResponse(res, { error: "invalid_username" }, 400);
2611
+ }
2612
+ const trimmed = username.trim();
2613
+ const hubClient = await this.resolveHubClientAware();
2614
+ const result = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/me/update-profile", {
2615
+ method: "POST",
2616
+ body: JSON.stringify({ username: trimmed }),
2617
+ }) as any;
2618
+ if (result.ok && result.userToken) {
2619
+ const sharing = this.ctx.config.sharing;
2620
+ if (sharing?.role === "hub") {
2621
+ try {
2622
+ const authPath = path.join(this.dataDir, "hub-auth.json");
2623
+ const authData = JSON.parse(fs.readFileSync(authPath, "utf8"));
2624
+ authData.bootstrapAdminToken = result.userToken;
2625
+ fs.writeFileSync(authPath, JSON.stringify(authData, null, 2), "utf-8");
2626
+ this.log.info("hub-auth.json updated with new admin token after username change");
2627
+ } catch (e) {
2628
+ this.log.warn(`Failed to update hub-auth.json: ${e}`);
2629
+ }
2630
+ } else {
2631
+ const persisted = this.store.getClientHubConnection();
2632
+ if (persisted) {
2633
+ this.store.setClientHubConnection({
2634
+ ...persisted,
2635
+ username: result.username,
2636
+ userToken: result.userToken,
2637
+ });
2638
+ }
2639
+ }
2640
+ }
2641
+ this.jsonResponse(res, result);
2642
+ } catch (err: any) {
2643
+ const msg = String(err?.message || err);
2644
+ if (msg.includes("409") || msg.includes("username_taken")) {
2645
+ return this.jsonResponse(res, { error: "username_taken" }, 409);
2646
+ }
2647
+ this.jsonResponse(res, { error: msg }, 500);
2648
+ }
2649
+ });
2650
+ }
2651
+
2652
+ private handleTestHubConnection(req: http.IncomingMessage, res: http.ServerResponse): void {
2653
+ this.readBody(req, async (body) => {
2654
+ try {
2655
+ const { hubUrl } = JSON.parse(body);
2656
+ if (!hubUrl) { this.jsonResponse(res, { ok: false, error: "hubUrl is required" }); return; }
2657
+ try {
2658
+ const localIPs = this.getLocalIPs();
2659
+ localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2660
+ const parsed = new URL(hubUrl.startsWith("http") ? hubUrl : `http://${hubUrl}`);
2661
+ if (localIPs.includes(parsed.hostname)) {
2662
+ this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
2663
+ return;
2664
+ }
2665
+ } catch {}
2666
+ const url = hubUrl.replace(/\/+$/, "") + "/api/v1/hub/info";
2667
+ const ctrl = new AbortController();
2668
+ const timeout = setTimeout(() => ctrl.abort(), 8000);
2669
+ try {
2670
+ const r = await fetch(url, { signal: ctrl.signal });
2671
+ clearTimeout(timeout);
2672
+ if (!r.ok) { this.jsonResponse(res, { ok: false, error: `HTTP ${r.status}` }); return; }
2673
+ const info = await r.json() as Record<string, unknown>;
2674
+ this.jsonResponse(res, { ok: true, teamName: info.teamName || "", apiVersion: info.apiVersion || "" });
2675
+ } catch (e: unknown) {
2676
+ clearTimeout(timeout);
2677
+ const msg = e instanceof Error ? e.message : String(e);
2678
+ this.jsonResponse(res, { ok: false, error: msg.includes("abort") ? "Connection timeout (8s)" : msg });
2679
+ }
2680
+ } catch (e) {
2681
+ this.jsonResponse(res, { ok: false, error: String(e) });
2682
+ }
2683
+ });
2684
+ }
2685
+
1071
2686
  private handleTestModel(req: http.IncomingMessage, res: http.ServerResponse): void {
1072
2687
  this.readBody(req, async (body) => {
1073
2688
  try {
@@ -2366,8 +3981,8 @@ export class ViewerServer {
2366
3981
  req.on("end", () => cb(body));
2367
3982
  }
2368
3983
 
2369
- private jsonResponse(res: http.ServerResponse, data: unknown): void {
2370
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
3984
+ private jsonResponse(res: http.ServerResponse, data: unknown, statusCode = 200): void {
3985
+ res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
2371
3986
  res.end(JSON.stringify(data));
2372
3987
  }
2373
3988
  }