@memtensor/memos-local-openclaw-plugin 1.0.4-beta.0 → 1.0.4-beta.10

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 (98) hide show
  1. package/.env.example +7 -0
  2. package/README.md +24 -24
  3. package/dist/capture/index.d.ts +1 -1
  4. package/dist/capture/index.d.ts.map +1 -1
  5. package/dist/capture/index.js +34 -2
  6. package/dist/capture/index.js.map +1 -1
  7. package/dist/client/connector.d.ts +5 -2
  8. package/dist/client/connector.d.ts.map +1 -1
  9. package/dist/client/connector.js +173 -14
  10. package/dist/client/connector.js.map +1 -1
  11. package/dist/client/hub.d.ts.map +1 -1
  12. package/dist/client/hub.js +22 -0
  13. package/dist/client/hub.js.map +1 -1
  14. package/dist/client/skill-sync.d.ts +7 -0
  15. package/dist/client/skill-sync.d.ts.map +1 -1
  16. package/dist/client/skill-sync.js +10 -0
  17. package/dist/client/skill-sync.js.map +1 -1
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +9 -11
  20. package/dist/config.js.map +1 -1
  21. package/dist/hub/server.d.ts +7 -0
  22. package/dist/hub/server.d.ts.map +1 -1
  23. package/dist/hub/server.js +301 -106
  24. package/dist/hub/server.js.map +1 -1
  25. package/dist/hub/user-manager.d.ts +3 -0
  26. package/dist/hub/user-manager.d.ts.map +1 -1
  27. package/dist/hub/user-manager.js +18 -1
  28. package/dist/hub/user-manager.js.map +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +7 -2
  31. package/dist/index.js.map +1 -1
  32. package/dist/ingest/providers/index.d.ts.map +1 -1
  33. package/dist/ingest/providers/index.js +37 -6
  34. package/dist/ingest/providers/index.js.map +1 -1
  35. package/dist/recall/engine.d.ts.map +1 -1
  36. package/dist/recall/engine.js +91 -1
  37. package/dist/recall/engine.js.map +1 -1
  38. package/dist/shared/llm-call.d.ts +1 -0
  39. package/dist/shared/llm-call.d.ts.map +1 -1
  40. package/dist/shared/llm-call.js +82 -8
  41. package/dist/shared/llm-call.js.map +1 -1
  42. package/dist/sharing/types.d.ts +1 -1
  43. package/dist/sharing/types.d.ts.map +1 -1
  44. package/dist/skill/evolver.d.ts +2 -0
  45. package/dist/skill/evolver.d.ts.map +1 -1
  46. package/dist/skill/evolver.js +3 -0
  47. package/dist/skill/evolver.js.map +1 -1
  48. package/dist/storage/ensure-binding.d.ts +12 -0
  49. package/dist/storage/ensure-binding.d.ts.map +1 -0
  50. package/dist/storage/ensure-binding.js +53 -0
  51. package/dist/storage/ensure-binding.js.map +1 -0
  52. package/dist/storage/sqlite.d.ts +74 -20
  53. package/dist/storage/sqlite.d.ts.map +1 -1
  54. package/dist/storage/sqlite.js +301 -207
  55. package/dist/storage/sqlite.js.map +1 -1
  56. package/dist/telemetry.d.ts +12 -5
  57. package/dist/telemetry.d.ts.map +1 -1
  58. package/dist/telemetry.js +156 -40
  59. package/dist/telemetry.js.map +1 -1
  60. package/dist/tools/memory-search.d.ts +3 -1
  61. package/dist/tools/memory-search.d.ts.map +1 -1
  62. package/dist/tools/memory-search.js +3 -1
  63. package/dist/tools/memory-search.js.map +1 -1
  64. package/dist/types.d.ts +1 -2
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js.map +1 -1
  67. package/dist/viewer/html.d.ts.map +1 -1
  68. package/dist/viewer/html.js +2991 -1041
  69. package/dist/viewer/html.js.map +1 -1
  70. package/dist/viewer/server.d.ts +32 -8
  71. package/dist/viewer/server.d.ts.map +1 -1
  72. package/dist/viewer/server.js +1122 -261
  73. package/dist/viewer/server.js.map +1 -1
  74. package/index.ts +384 -43
  75. package/openclaw.plugin.json +1 -1
  76. package/package.json +3 -2
  77. package/scripts/postinstall.cjs +1 -1
  78. package/skill/memos-memory-guide/SKILL.md +64 -26
  79. package/src/capture/index.ts +37 -1
  80. package/src/client/connector.ts +173 -16
  81. package/src/client/hub.ts +18 -0
  82. package/src/client/skill-sync.ts +14 -0
  83. package/src/config.ts +9 -11
  84. package/src/hub/server.ts +285 -98
  85. package/src/hub/user-manager.ts +20 -3
  86. package/src/index.ts +10 -2
  87. package/src/ingest/providers/index.ts +41 -7
  88. package/src/recall/engine.ts +84 -1
  89. package/src/shared/llm-call.ts +97 -9
  90. package/src/sharing/types.ts +1 -1
  91. package/src/skill/evolver.ts +5 -0
  92. package/src/storage/ensure-binding.ts +52 -0
  93. package/src/storage/sqlite.ts +310 -233
  94. package/src/telemetry.ts +172 -41
  95. package/src/tools/memory-search.ts +2 -1
  96. package/src/types.ts +1 -2
  97. package/src/viewer/html.ts +2991 -1041
  98. package/src/viewer/server.ts +984 -190
@@ -84,6 +84,12 @@ export class ViewerServer {
84
84
  { running: false, done: false, stopped: false, processed: 0, total: 0, tasksCreated: 0, skillsCreated: 0, errors: 0, skippedSessions: 0, totalSessions: 0 };
85
85
  private ppSSEClients: http.ServerResponse[] = [];
86
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
+
87
93
  constructor(opts: ViewerServerOptions) {
88
94
  this.store = opts.store;
89
95
  this.embedder = opts.embedder;
@@ -103,15 +109,16 @@ export class ViewerServer {
103
109
  this.server.on("error", (err: NodeJS.ErrnoException) => {
104
110
  if (err.code === "EADDRINUSE") {
105
111
  this.log.warn(`Viewer port ${this.port} in use, trying ${this.port + 1}`);
106
- this.server!.listen(this.port + 1, "127.0.0.1");
112
+ this.server!.listen(this.port + 1, "0.0.0.0");
107
113
  } else {
108
114
  reject(err);
109
115
  }
110
116
  });
111
- this.server.listen(this.port, "127.0.0.1", () => {
117
+ this.server.listen(this.port, "0.0.0.0", () => {
112
118
  const addr = this.server!.address();
113
119
  const actualPort = typeof addr === "object" && addr ? addr.port : this.port;
114
120
  this.autoCleanupPolluted();
121
+ this.startHubHeartbeat();
115
122
  resolve(`http://127.0.0.1:${actualPort}`);
116
123
  });
117
124
  });
@@ -134,6 +141,10 @@ export class ViewerServer {
134
141
  }
135
142
 
136
143
  stop(): void {
144
+ this.stopHubHeartbeat();
145
+ this.stopNotifPoll();
146
+ for (const c of this.notifSSEClients) { try { c.end(); } catch {} }
147
+ this.notifSSEClients = [];
137
148
  this.server?.close();
138
149
  this.server = null;
139
150
  }
@@ -224,6 +235,11 @@ export class ViewerServer {
224
235
  }
225
236
 
226
237
  if (p === "/api/memories" && req.method === "GET") this.serveMemories(res, url);
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);
227
243
  else if (p === "/api/stats") this.serveStats(res, url);
228
244
  else if (p === "/api/metrics") this.serveMetrics(res, url);
229
245
  else if (p === "/api/tool-metrics") this.serveToolMetrics(res, url);
@@ -251,6 +267,9 @@ export class ViewerServer {
251
267
  else if (p === "/api/sharing/pending-users" && req.method === "GET") this.serveSharingPendingUsers(res);
252
268
  else if (p === "/api/sharing/approve-user" && req.method === "POST") this.handleSharingApproveUser(req, res);
253
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);
254
273
  else if (p === "/api/sharing/search/memories" && req.method === "POST") this.handleSharingMemorySearch(req, res);
255
274
  else if (p === "/api/sharing/memories/list" && req.method === "GET") this.serveSharingMemoryList(res, url);
256
275
  else if (p === "/api/sharing/tasks/list" && req.method === "GET") this.serveSharingTaskList(res, url);
@@ -260,23 +279,23 @@ export class ViewerServer {
260
279
  else if (p === "/api/sharing/tasks/share" && req.method === "POST") this.handleSharingTaskShare(req, res);
261
280
  else if (p === "/api/sharing/tasks/unshare" && req.method === "POST") this.handleSharingTaskUnshare(req, res);
262
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);
263
283
  else if (p === "/api/sharing/test-hub" && req.method === "POST") this.handleTestHubConnection(req, res);
264
284
  else if (p === "/api/sharing/memories/share" && req.method === "POST") this.handleSharingMemoryShare(req, res);
265
285
  else if (p === "/api/sharing/memories/unshare" && req.method === "POST") this.handleSharingMemoryUnshare(req, res);
266
286
  else if (p === "/api/sharing/skills/pull" && req.method === "POST") this.handleSharingSkillPull(req, res);
267
287
  else if (p === "/api/sharing/skills/share" && req.method === "POST") this.handleSharingSkillShare(req, res);
268
288
  else if (p === "/api/sharing/skills/unshare" && req.method === "POST") this.handleSharingSkillUnshare(req, res);
269
- else if (p === "/api/sharing/groups" && req.method === "GET") this.serveSharingGroups(res);
270
- else if (p === "/api/sharing/groups" && req.method === "POST") this.handleSharingGroupCreate(req, res);
271
- else if (p.match(/^\/api\/sharing\/groups\/[^/]+$/) && req.method === "PUT") this.handleSharingGroupUpdate(req, res, p);
272
- else if (p.match(/^\/api\/sharing\/groups\/[^/]+$/) && req.method === "DELETE") this.handleSharingGroupDelete(res, p);
273
- else if (p.match(/^\/api\/sharing\/groups\/[^/]+\/members$/) && req.method === "GET") this.serveSharingGroupMembers(res, p);
274
- else if (p.match(/^\/api\/sharing\/groups\/[^/]+\/members$/) && req.method === "POST") this.handleSharingGroupAddMember(req, res, p);
275
- else if (p.match(/^\/api\/sharing\/groups\/[^/]+\/members$/) && req.method === "DELETE") this.handleSharingGroupRemoveMember(req, res, p);
276
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);
277
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);
278
296
  else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteTask(res, p);
279
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);
280
299
  else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteSkill(res, p);
281
300
  else if (p === "/api/admin/shared-memories" && req.method === "GET") this.serveAdminSharedMemories(res);
282
301
  else if (p.match(/^\/api\/admin\/shared-memories\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteMemory(res, p);
@@ -431,22 +450,30 @@ export class ViewerServer {
431
450
  const params: any[] = [];
432
451
  if (session) { conditions.push("session_key = ?"); params.push(session); }
433
452
  if (role) { conditions.push("role = ?"); params.push(role); }
434
- if (owner) { conditions.push("owner = ?"); params.push(owner); }
453
+ if (owner && owner.startsWith("agent:")) {
454
+ conditions.push("(owner = ? OR owner = 'public')");
455
+ params.push(owner);
456
+ } else if (owner) {
457
+ conditions.push("owner = ?"); params.push(owner);
458
+ }
435
459
  if (dateFrom) { conditions.push("created_at >= ?"); params.push(new Date(dateFrom).getTime()); }
436
460
  if (dateTo) { conditions.push("created_at <= ?"); params.push(new Date(dateTo).getTime()); }
437
461
 
438
462
  const where = conditions.length > 0 ? " WHERE " + conditions.join(" AND ") : "";
439
463
  const totalRow = db.prepare("SELECT COUNT(*) as count FROM chunks" + where).get(...params) as any;
440
- const rawMemories = db.prepare("SELECT * FROM chunks" + where + ` ORDER BY created_at ${sortBy} LIMIT ? OFFSET ?`).all(...params, limit, offset);
464
+ const rawMemories = db.prepare("SELECT * FROM chunks" + where + ` ORDER BY CASE WHEN dedup_status IN ('duplicate','merged') THEN 1 ELSE 0 END ASC, created_at ${sortBy} LIMIT ? OFFSET ?`).all(...params, limit, offset);
441
465
  const findMergeSources = db.prepare("SELECT id, summary, role FROM chunks WHERE dedup_target = ? AND (dedup_status = 'merged' OR dedup_status = 'duplicate')");
442
466
 
443
467
  const chunkIds = rawMemories.map((m: any) => m.id);
444
468
  const sharingMap = new Map<string, { visibility: string; group_id: string | null }>();
469
+ const localShareMap = new Map<string, { original_owner: string; shared_at: number }>();
445
470
  if (chunkIds.length > 0) {
446
471
  try {
447
472
  const placeholders = chunkIds.map(() => "?").join(",");
448
473
  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 }>;
449
474
  for (const r of sharedRows) sharingMap.set(r.source_chunk_id, r);
475
+ 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 }>;
476
+ for (const r of localRows) localShareMap.set(r.chunk_id, r);
450
477
  } catch {
451
478
  }
452
479
  }
@@ -457,8 +484,12 @@ export class ViewerServer {
457
484
  out.merge_sources = sources;
458
485
  }
459
486
  const shared = sharingMap.get(m.id);
487
+ const localShared = localShareMap.get(m.id);
460
488
  out.sharingVisibility = shared?.visibility ?? null;
461
489
  out.sharingGroupId = shared?.group_id ?? null;
490
+ out.localSharing = out.owner === "public";
491
+ out.localSharingManaged = !!localShared;
492
+ out.localOriginalOwner = localShared?.original_owner ?? null;
462
493
  return out;
463
494
  });
464
495
 
@@ -476,7 +507,21 @@ export class ViewerServer {
476
507
  }
477
508
 
478
509
  private serveToolMetrics(res: http.ServerResponse, url: URL): void {
479
- const minutes = Math.min(1440, Math.max(10, Number(url.searchParams.get("minutes")) || 60));
510
+ const fromParam = url.searchParams.get("from");
511
+ const toParam = url.searchParams.get("to");
512
+ if (fromParam) {
513
+ const fromMs = new Date(fromParam).getTime();
514
+ const toMs = toParam ? new Date(toParam).getTime() : Date.now();
515
+ if (isNaN(fromMs) || isNaN(toMs)) {
516
+ this.jsonResponse(res, { error: "Invalid date" }, 400);
517
+ return;
518
+ }
519
+ const diffMin = Math.max(10, Math.min(43200, Math.round((toMs - fromMs) / 60000)));
520
+ const data = this.store.getToolMetrics(diffMin, fromMs, toMs);
521
+ this.jsonResponse(res, data);
522
+ return;
523
+ }
524
+ const minutes = Math.min(43200, Math.max(10, Number(url.searchParams.get("minutes")) || 60));
480
525
  const data = this.store.getToolMetrics(minutes);
481
526
  this.jsonResponse(res, data);
482
527
  }
@@ -484,13 +529,15 @@ export class ViewerServer {
484
529
  private serveTasks(res: http.ServerResponse, url: URL): void {
485
530
  this.store.recordViewerEvent("tasks_list");
486
531
  const status = url.searchParams.get("status") ?? undefined;
532
+ const owner = url.searchParams.get("owner") ?? undefined;
487
533
  const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit")) || 50));
488
534
  const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
489
- const { tasks, total } = this.store.listTasks({ status, limit, offset });
535
+ const { tasks, total } = this.store.listTasks({ status, limit, offset, owner });
490
536
 
491
537
  const db = (this.store as any).db;
492
538
  const items = tasks.map((t) => {
493
- const meta = db.prepare("SELECT skill_status FROM tasks WHERE id = ?").get(t.id) as { skill_status: string | null } | undefined;
539
+ const meta = db.prepare("SELECT skill_status, owner FROM tasks WHERE id = ?").get(t.id) as { skill_status: string | null; owner: string | null } | undefined;
540
+ 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;
494
541
  return {
495
542
  id: t.id,
496
543
  sessionKey: t.sessionKey,
@@ -501,6 +548,8 @@ export class ViewerServer {
501
548
  endedAt: t.endedAt,
502
549
  chunkCount: this.store.countChunksByTask(t.id),
503
550
  skillStatus: meta?.skill_status ?? null,
551
+ owner: meta?.owner ?? "agent:main",
552
+ sharingVisibility: sharedTask?.visibility ?? null,
504
553
  };
505
554
  });
506
555
 
@@ -543,6 +592,7 @@ export class ViewerServer {
543
592
  title: task.title,
544
593
  summary: task.summary,
545
594
  status: task.status,
595
+ owner: task.owner ?? "agent:main",
546
596
  startedAt: task.startedAt,
547
597
  endedAt: task.endedAt,
548
598
  chunks: chunkItems,
@@ -551,6 +601,7 @@ export class ViewerServer {
551
601
  skillLinks,
552
602
  sharingVisibility: sharedTask?.visibility ?? null,
553
603
  sharingGroupId: sharedTask?.group_id ?? null,
604
+ hubTaskId: sharedTask ? true : false,
554
605
  });
555
606
  }
556
607
 
@@ -585,12 +636,19 @@ export class ViewerServer {
585
636
  }
586
637
  let embCount = 0;
587
638
  try { embCount = (db.prepare("SELECT COUNT(*) as count FROM embeddings").get() as any).count; } catch { /* table may not exist */ }
588
- const sessionQuery = ownerFilter
589
- ? "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"
590
- : "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";
591
- const sessionList = (ownerFilter
592
- ? db.prepare(sessionQuery).all(ownerFilter)
593
- : db.prepare(sessionQuery).all()) as any[];
639
+ let sessionQuery: string;
640
+ let sessionParams: any[];
641
+ if (ownerFilter && ownerFilter.startsWith("agent:")) {
642
+ sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks WHERE (owner = ? OR owner = 'public') GROUP BY session_key ORDER BY latest DESC";
643
+ sessionParams = [ownerFilter];
644
+ } else if (ownerFilter) {
645
+ 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";
646
+ sessionParams = [ownerFilter];
647
+ } else {
648
+ 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";
649
+ sessionParams = [];
650
+ }
651
+ const sessionList = db.prepare(sessionQuery).all(...sessionParams) as any[];
594
652
 
595
653
  let skillCount = 0;
596
654
  try { skillCount = (db.prepare("SELECT COUNT(*) as count FROM skills").get() as any).count; } catch { /* table may not exist yet */ }
@@ -603,10 +661,16 @@ export class ViewerServer {
603
661
 
604
662
  let owners: string[] = [];
605
663
  try {
606
- const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL ORDER BY owner").all() as any[];
664
+ const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL AND owner LIKE 'agent:%' ORDER BY owner").all() as any[];
607
665
  owners = ownerRows.map((o: any) => o.owner);
608
666
  } catch { /* column may not exist yet */ }
609
667
 
668
+ let currentAgentOwner = "agent:main";
669
+ try {
670
+ const latest = db.prepare("SELECT owner FROM chunks WHERE owner IS NOT NULL AND owner LIKE 'agent:%' ORDER BY created_at DESC LIMIT 1").get() as any;
671
+ if (latest?.owner) currentAgentOwner = latest.owner;
672
+ } catch { /* best-effort */ }
673
+
610
674
  this.jsonResponse(res, {
611
675
  totalMemories: total.count, totalSessions: sessions.count, totalEmbeddings: embCount,
612
676
  totalSkills: skillCount,
@@ -615,6 +679,7 @@ export class ViewerServer {
615
679
  timeRange: { earliest: timeRange.earliest, latest: timeRange.latest },
616
680
  sessions: sessionList,
617
681
  owners,
682
+ currentAgentOwner,
618
683
  });
619
684
  } catch (e) {
620
685
  this.log.warn(`stats error: ${e}`);
@@ -726,7 +791,12 @@ export class ViewerServer {
726
791
  if (visibility) {
727
792
  skills = skills.filter(s => s.visibility === visibility);
728
793
  }
729
- this.jsonResponse(res, { skills });
794
+ const db = (this.store as any).db;
795
+ const enriched = skills.map(s => {
796
+ 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;
797
+ return { ...s, sharingVisibility: hub?.visibility ?? null };
798
+ });
799
+ this.jsonResponse(res, { skills: enriched });
730
800
  }
731
801
 
732
802
  private serveSkillDetail(res: http.ServerResponse, urlPath: string): void {
@@ -977,11 +1047,21 @@ export class ViewerServer {
977
1047
  });
978
1048
  }
979
1049
 
980
- private handleSkillDelete(res: http.ServerResponse, urlPath: string): void {
1050
+ private async handleSkillDelete(res: http.ServerResponse, urlPath: string): Promise<void> {
981
1051
  const skillId = urlPath.replace("/api/skill/", "");
982
1052
  const skill = this.store.getSkill(skillId);
983
1053
  if (!skill) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Skill not found" })); return; }
984
- // Remove skill directory from disk
1054
+ try {
1055
+ const hub = this.resolveHubConnection();
1056
+ if (hub) {
1057
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/skills/unpublish", {
1058
+ method: "POST",
1059
+ body: JSON.stringify({ sourceSkillId: skillId }),
1060
+ }).catch(() => {});
1061
+ }
1062
+ const db = (this.store as any).db;
1063
+ db.prepare("DELETE FROM hub_skills WHERE source_skill_id = ?").run(skillId);
1064
+ } catch (_) {}
985
1065
  try {
986
1066
  if (skill.dirPath && fs.existsSync(skill.dirPath)) {
987
1067
  fs.rmSync(skill.dirPath, { recursive: true, force: true });
@@ -1028,7 +1108,15 @@ export class ViewerServer {
1028
1108
  const cleaned = chunk.role === "user" && chunk.content
1029
1109
  ? { ...chunk, content: stripInboundMetadata(chunk.content) }
1030
1110
  : chunk;
1031
- this.jsonResponse(res, { memory: cleaned });
1111
+ const localShared = this.store.getLocalSharedMemory(chunkId);
1112
+ this.jsonResponse(res, {
1113
+ memory: {
1114
+ ...cleaned,
1115
+ localSharing: cleaned.owner === "public",
1116
+ localSharingManaged: !!localShared,
1117
+ localOriginalOwner: localShared?.originalOwner ?? null,
1118
+ },
1119
+ });
1032
1120
  }
1033
1121
 
1034
1122
  private handleUpdate(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
@@ -1057,6 +1145,349 @@ export class ViewerServer {
1057
1145
  else { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found" })); }
1058
1146
  }
1059
1147
 
1148
+ private handleMemoryLocalShare(req: http.IncomingMessage, res: http.ServerResponse): void {
1149
+ this.readBody(req, (body) => {
1150
+ try {
1151
+ const parsed = JSON.parse(body || "{}");
1152
+ const chunkId = String(parsed.chunkId || "");
1153
+ if (!chunkId) return this.jsonResponse(res, { ok: false, error: "missing_chunk_id" }, 400);
1154
+ const result = this.store.markMemorySharedLocally(chunkId);
1155
+ if (!result.ok) {
1156
+ return this.jsonResponse(res, { ok: false, error: result.reason ?? "share_failed" }, result.reason === "not_found" ? 404 : 400);
1157
+ }
1158
+ this.jsonResponse(res, {
1159
+ ok: true,
1160
+ chunkId,
1161
+ owner: result.owner,
1162
+ localSharing: true,
1163
+ localSharingManaged: true,
1164
+ localOriginalOwner: result.originalOwner ?? null,
1165
+ });
1166
+ } catch (err) {
1167
+ this.jsonResponse(res, { ok: false, error: String(err) }, 400);
1168
+ }
1169
+ });
1170
+ }
1171
+
1172
+ private handleMemoryLocalUnshare(req: http.IncomingMessage, res: http.ServerResponse): void {
1173
+ this.readBody(req, (body) => {
1174
+ try {
1175
+ const parsed = JSON.parse(body || "{}");
1176
+ const chunkId = String(parsed.chunkId || "");
1177
+ const privateOwner = typeof parsed.privateOwner === "string" ? parsed.privateOwner : undefined;
1178
+ if (!chunkId) return this.jsonResponse(res, { ok: false, error: "missing_chunk_id" }, 400);
1179
+ const result = this.store.unmarkMemorySharedLocally(chunkId, privateOwner);
1180
+ if (!result.ok) {
1181
+ return this.jsonResponse(res, { ok: false, error: result.reason ?? "unshare_failed" }, result.reason === "not_found" ? 404 : 400);
1182
+ }
1183
+ this.jsonResponse(res, {
1184
+ ok: true,
1185
+ chunkId,
1186
+ owner: result.owner,
1187
+ localSharing: false,
1188
+ localOriginalOwner: result.originalOwner ?? null,
1189
+ });
1190
+ } catch (err) {
1191
+ this.jsonResponse(res, { ok: false, error: String(err) }, 400);
1192
+ }
1193
+ });
1194
+ }
1195
+
1196
+ // ─── Unified scope API ───
1197
+
1198
+ private handleMemoryScope(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
1199
+ const chunkId = urlPath.split("/")[3];
1200
+ this.readBody(req, async (body) => {
1201
+ try {
1202
+ const parsed = JSON.parse(body || "{}");
1203
+ const scope = parsed.scope as string;
1204
+ if (!["private", "local", "team"].includes(scope)) {
1205
+ return this.jsonResponse(res, { ok: false, error: "scope must be 'private', 'local', or 'team'" }, 400);
1206
+ }
1207
+ const db = (this.store as any).db;
1208
+ const chunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId) as any;
1209
+ if (!chunk) return this.jsonResponse(res, { ok: false, error: "not_found" }, 404);
1210
+
1211
+ if (chunk.dedup_status && chunk.dedup_status !== "active") {
1212
+ return this.jsonResponse(res, { ok: false, error: "inactive_memory", message: "Merged/duplicate memories cannot be shared" }, 400);
1213
+ }
1214
+
1215
+ const isLocalShared = chunk.owner === "public";
1216
+ const hubMemory = this.getHubMemoryForChunk(chunkId);
1217
+ const isTeamShared = !!hubMemory;
1218
+ const currentScope = isTeamShared ? "team" : isLocalShared ? "local" : "private";
1219
+
1220
+ if (scope === currentScope) {
1221
+ return this.jsonResponse(res, { ok: true, scope, changed: false });
1222
+ }
1223
+
1224
+ let hubSynced = false;
1225
+
1226
+ if (scope === "team") {
1227
+ if (!isTeamShared) {
1228
+ const hubClient = await this.resolveHubClientAware();
1229
+ const refreshedChunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId) as any;
1230
+ const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/share", {
1231
+ method: "POST",
1232
+ body: JSON.stringify({ memory: { sourceChunkId: refreshedChunk.id, role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary, kind: refreshedChunk.kind, groupId: null, visibility: "public" } }),
1233
+ });
1234
+ if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
1235
+ if (hubClient.userId) {
1236
+ const existing = this.store.getHubMemoryBySource(hubClient.userId, chunkId);
1237
+ this.store.upsertHubMemory({
1238
+ id: (response as any)?.memoryId ?? existing?.id ?? crypto.randomUUID(),
1239
+ sourceChunkId: chunkId, sourceUserId: hubClient.userId,
1240
+ role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary ?? "",
1241
+ kind: refreshedChunk.kind, groupId: null, visibility: "public",
1242
+ createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1243
+ });
1244
+ }
1245
+ hubSynced = true;
1246
+ } else {
1247
+ if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
1248
+ }
1249
+ } else if (scope === "local") {
1250
+ if (isTeamShared) {
1251
+ try {
1252
+ const hubClient = await this.resolveHubClientAware();
1253
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/unshare", {
1254
+ method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
1255
+ });
1256
+ if (hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1257
+ hubSynced = true;
1258
+ } catch (err) { this.log.warn(`Failed to unshare memory from team: ${err}`); }
1259
+ }
1260
+ if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
1261
+ } else {
1262
+ if (isTeamShared) {
1263
+ try {
1264
+ const hubClient = await this.resolveHubClientAware();
1265
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/unshare", {
1266
+ method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
1267
+ });
1268
+ if (hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1269
+ hubSynced = true;
1270
+ } catch (err) { this.log.warn(`Failed to unshare memory from team: ${err}`); }
1271
+ }
1272
+ if (isLocalShared) this.store.unmarkMemorySharedLocally(chunkId);
1273
+ }
1274
+
1275
+ this.jsonResponse(res, { ok: true, scope, changed: true, hubSynced });
1276
+ } catch (err) {
1277
+ this.jsonResponse(res, { ok: false, error: String(err) }, 500);
1278
+ }
1279
+ });
1280
+ }
1281
+
1282
+ private handleTaskScope(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
1283
+ const taskId = urlPath.split("/")[3];
1284
+ this.readBody(req, async (body) => {
1285
+ try {
1286
+ const parsed = JSON.parse(body || "{}");
1287
+ const scope = parsed.scope as string;
1288
+ if (!["private", "local", "team"].includes(scope)) {
1289
+ return this.jsonResponse(res, { ok: false, error: "scope must be 'private', 'local', or 'team'" }, 400);
1290
+ }
1291
+ const task = this.store.getTask(taskId);
1292
+ if (!task) return this.jsonResponse(res, { ok: false, error: "task_not_found" }, 404);
1293
+
1294
+ if (scope !== "private" && task.status !== "completed") {
1295
+ return this.jsonResponse(res, { ok: false, error: "only_completed_tasks_can_be_shared" }, 400);
1296
+ }
1297
+
1298
+ const isLocalShared = task.owner === "public";
1299
+ const hubTask = this.getHubTaskForLocal(taskId);
1300
+ const isTeamShared = !!hubTask;
1301
+ const currentScope = isTeamShared ? "team" : isLocalShared ? "local" : "private";
1302
+
1303
+ if (scope === currentScope) {
1304
+ return this.jsonResponse(res, { ok: true, scope, changed: false });
1305
+ }
1306
+
1307
+ let hubSynced = false;
1308
+
1309
+ if (scope === "team") {
1310
+ if (!isTeamShared) {
1311
+ const chunks = this.store.getChunksByTask(taskId);
1312
+ const hubClient = await this.resolveHubClientAware();
1313
+ const refreshedTask = this.store.getTask(taskId)!;
1314
+ const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/share", {
1315
+ method: "POST",
1316
+ body: JSON.stringify({
1317
+ 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() },
1318
+ 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() })),
1319
+ }),
1320
+ });
1321
+ if (hubClient.userId) {
1322
+ const existing = this.store.getHubTaskBySource(hubClient.userId, taskId);
1323
+ this.store.upsertHubTask({
1324
+ id: (response as any)?.taskId ?? existing?.id ?? crypto.randomUUID(),
1325
+ sourceTaskId: taskId, sourceUserId: hubClient.userId, title: refreshedTask.title ?? "",
1326
+ summary: refreshedTask.summary ?? "", groupId: null, visibility: "public",
1327
+ createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1328
+ });
1329
+ }
1330
+ hubSynced = true;
1331
+ }
1332
+ if (!isLocalShared) {
1333
+ const originalOwner = task.owner;
1334
+ const db = (this.store as any).db;
1335
+ 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());
1336
+ db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1337
+ }
1338
+ }
1339
+
1340
+ if (scope === "local") {
1341
+ if (!isLocalShared) {
1342
+ const originalOwner = task.owner;
1343
+ const db = (this.store as any).db;
1344
+ 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());
1345
+ db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1346
+ }
1347
+ }
1348
+
1349
+ if (scope === "local" && isTeamShared) {
1350
+ try {
1351
+ const hubClient = await this.resolveHubClientAware();
1352
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/unshare", {
1353
+ method: "POST", body: JSON.stringify({ sourceTaskId: taskId }),
1354
+ });
1355
+ if (hubClient.userId) this.store.deleteHubTaskBySource(hubClient.userId, taskId);
1356
+ hubSynced = true;
1357
+ } catch (err) { this.log.warn(`Failed to unshare task from team: ${err}`); }
1358
+ }
1359
+
1360
+ if (scope === "private") {
1361
+ if (isTeamShared) {
1362
+ try {
1363
+ const hubClient = await this.resolveHubClientAware();
1364
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/unshare", {
1365
+ method: "POST", body: JSON.stringify({ sourceTaskId: taskId }),
1366
+ });
1367
+ if (hubClient.userId) this.store.deleteHubTaskBySource(hubClient.userId, taskId);
1368
+ hubSynced = true;
1369
+ } catch (err) { this.log.warn(`Failed to unshare task from team: ${err}`); }
1370
+ }
1371
+ if (isLocalShared) {
1372
+ const db = (this.store as any).db;
1373
+ const shared = db.prepare("SELECT original_owner FROM local_shared_tasks WHERE task_id = ?").get(taskId) as any;
1374
+ const restoreOwner = shared?.original_owner ?? task.owner;
1375
+ if (restoreOwner && restoreOwner !== "public") {
1376
+ db.prepare("UPDATE tasks SET owner = ? WHERE id = ?").run(restoreOwner, taskId);
1377
+ }
1378
+ db.prepare("DELETE FROM local_shared_tasks WHERE task_id = ?").run(taskId);
1379
+ }
1380
+ }
1381
+
1382
+ this.jsonResponse(res, { ok: true, scope, changed: true, hubSynced });
1383
+ } catch (err) {
1384
+ this.jsonResponse(res, { ok: false, error: String(err) }, 500);
1385
+ }
1386
+ });
1387
+ }
1388
+
1389
+ private handleSkillScope(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
1390
+ const skillId = urlPath.split("/")[3];
1391
+ this.readBody(req, async (body) => {
1392
+ try {
1393
+ const parsed = JSON.parse(body || "{}");
1394
+ const scope = parsed.scope as string;
1395
+ if (!["private", "local", "team"].includes(scope)) {
1396
+ return this.jsonResponse(res, { ok: false, error: "scope must be 'private', 'local', or 'team'" }, 400);
1397
+ }
1398
+ const skill = this.store.getSkill(skillId);
1399
+ if (!skill) return this.jsonResponse(res, { ok: false, error: "skill_not_found" }, 404);
1400
+
1401
+ if (scope !== "private" && skill.status !== "active") {
1402
+ return this.jsonResponse(res, { ok: false, error: "only_active_skills_can_be_shared" }, 400);
1403
+ }
1404
+
1405
+ const isLocalShared = skill.visibility === "public";
1406
+ const hubSkill = this.getHubSkillForLocal(skillId);
1407
+ const isTeamShared = !!hubSkill;
1408
+ const currentScope = isTeamShared ? "team" : isLocalShared ? "local" : "private";
1409
+
1410
+ if (scope === currentScope) {
1411
+ return this.jsonResponse(res, { ok: true, scope, changed: false });
1412
+ }
1413
+
1414
+ let hubSynced = false;
1415
+
1416
+ if (scope === "team") {
1417
+ if (!isTeamShared) {
1418
+ const bundle = buildSkillBundleForHub(this.store, skillId);
1419
+ const hubClient = await this.resolveHubClientAware();
1420
+ const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/publish", {
1421
+ method: "POST",
1422
+ body: JSON.stringify({ visibility: "public", groupId: null, metadata: bundle.metadata, bundle: bundle.bundle }),
1423
+ });
1424
+ if (hubClient.userId) {
1425
+ const existing = this.store.getHubSkillBySource(hubClient.userId, skillId);
1426
+ this.store.upsertHubSkill({
1427
+ id: (response as any)?.skillId ?? existing?.id ?? crypto.randomUUID(),
1428
+ sourceSkillId: skillId, sourceUserId: hubClient.userId,
1429
+ name: skill.name, description: skill.description, version: skill.version,
1430
+ groupId: null, visibility: "public",
1431
+ bundle: JSON.stringify(bundle.bundle), qualityScore: skill.qualityScore,
1432
+ createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1433
+ });
1434
+ }
1435
+ hubSynced = true;
1436
+ }
1437
+ if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
1438
+ }
1439
+
1440
+ if (scope === "local") {
1441
+ if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
1442
+ }
1443
+
1444
+ if (scope === "local" && isTeamShared) {
1445
+ try {
1446
+ const hubClient = await this.resolveHubClientAware();
1447
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/unpublish", {
1448
+ method: "POST", body: JSON.stringify({ sourceSkillId: skillId }),
1449
+ });
1450
+ if (hubClient.userId) this.store.deleteHubSkillBySource(hubClient.userId, skillId);
1451
+ hubSynced = true;
1452
+ } catch (err) { this.log.warn(`Failed to unpublish skill from team: ${err}`); }
1453
+ }
1454
+
1455
+ if (scope === "private") {
1456
+ if (isTeamShared) {
1457
+ try {
1458
+ const hubClient = await this.resolveHubClientAware();
1459
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/unpublish", {
1460
+ method: "POST", body: JSON.stringify({ sourceSkillId: skillId }),
1461
+ });
1462
+ if (hubClient.userId) this.store.deleteHubSkillBySource(hubClient.userId, skillId);
1463
+ hubSynced = true;
1464
+ } catch (err) { this.log.warn(`Failed to unpublish skill from team: ${err}`); }
1465
+ }
1466
+ if (isLocalShared) this.store.setSkillVisibility(skillId, "private");
1467
+ }
1468
+
1469
+ this.jsonResponse(res, { ok: true, scope, changed: true, hubSynced });
1470
+ } catch (err) {
1471
+ this.jsonResponse(res, { ok: false, error: String(err) }, 500);
1472
+ }
1473
+ });
1474
+ }
1475
+
1476
+ private getHubMemoryForChunk(chunkId: string): any {
1477
+ const db = (this.store as any).db;
1478
+ return db.prepare("SELECT * FROM hub_memories WHERE source_chunk_id = ? LIMIT 1").get(chunkId);
1479
+ }
1480
+
1481
+ private getHubTaskForLocal(taskId: string): any {
1482
+ const db = (this.store as any).db;
1483
+ return db.prepare("SELECT * FROM hub_tasks WHERE source_task_id = ? LIMIT 1").get(taskId);
1484
+ }
1485
+
1486
+ private getHubSkillForLocal(skillId: string): any {
1487
+ const db = (this.store as any).db;
1488
+ return db.prepare("SELECT * FROM hub_skills WHERE source_skill_id = ? LIMIT 1").get(skillId);
1489
+ }
1490
+
1060
1491
  private handleDeleteSession(res: http.ServerResponse, url: URL): void {
1061
1492
  const key = url.searchParams.get("key");
1062
1493
  if (!key) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Missing key" })); return; }
@@ -1092,7 +1523,8 @@ export class ViewerServer {
1092
1523
 
1093
1524
  private getOpenClawConfigPath(): string {
1094
1525
  const home = process.env.HOME || process.env.USERPROFILE || "";
1095
- return path.join(home, ".openclaw", "openclaw.json");
1526
+ const ocHome = process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
1527
+ return path.join(ocHome, "openclaw.json");
1096
1528
  }
1097
1529
 
1098
1530
  private getPluginEntryConfig(raw: any): Record<string, unknown> {
@@ -1156,8 +1588,7 @@ export class ViewerServer {
1156
1588
  base.connection.connected = true;
1157
1589
  base.connection.hubUrl = resolvedHubUrl ?? undefined;
1158
1590
 
1159
- // 通过 hub API 获取 admin 用户的真实信息(含分组)
1160
- let adminUser: any = { username: "hub-admin", role: "admin", groups: [] };
1591
+ let adminUser: any = { username: "hub-admin", role: "admin" };
1161
1592
  try {
1162
1593
  const hub = this.resolveHubConnection();
1163
1594
  if (hub) {
@@ -1167,7 +1598,6 @@ export class ViewerServer {
1167
1598
  id: me.id,
1168
1599
  username: me.username ?? "hub-admin",
1169
1600
  role: me.role ?? "admin",
1170
- groups: Array.isArray(me.groups) ? me.groups : [],
1171
1601
  };
1172
1602
  }
1173
1603
  }
@@ -1185,7 +1615,8 @@ export class ViewerServer {
1185
1615
  return;
1186
1616
  }
1187
1617
 
1188
- if (!hasClientConfig) {
1618
+ const hasPendingConnection = Boolean(persisted?.hubUrl && persisted?.userId && !persisted?.userToken);
1619
+ if (!hasClientConfig && !hasPendingConnection) {
1189
1620
  this.jsonResponse(res, base);
1190
1621
  return;
1191
1622
  }
@@ -1193,12 +1624,26 @@ export class ViewerServer {
1193
1624
  try {
1194
1625
  const status = await getHubStatus(this.store, this.ctx.config);
1195
1626
  const output = { ...base, connection: { ...base.connection, ...status } } as any;
1627
+ if (status.user?.status === "pending") {
1628
+ output.connection.pendingApproval = true;
1629
+ }
1630
+ if (status.user?.status === "rejected") {
1631
+ output.connection.rejected = true;
1632
+ }
1633
+ if (status.user?.status === "removed") {
1634
+ output.connection.removed = true;
1635
+ }
1196
1636
  if (status.connected && status.hubUrl) {
1197
1637
  try {
1198
1638
  const info = await fetch(`${status.hubUrl}/api/v1/hub/info`).then((r) => (r.ok ? r.json() : null)).catch(() => null) as any;
1199
1639
  output.connection.teamName = info?.teamName ?? null;
1200
1640
  output.connection.apiVersion = info?.apiVersion ?? null;
1201
1641
  } catch {}
1642
+ } else if (status.hubUrl) {
1643
+ try {
1644
+ const info = await fetch(`${status.hubUrl}/api/v1/hub/info`).then((r) => (r.ok ? r.json() : null)).catch(() => null) as any;
1645
+ output.connection.teamName = info?.teamName ?? null;
1646
+ } catch {}
1202
1647
  }
1203
1648
  output.admin.canManageUsers = status.connected && status.user?.role === "admin";
1204
1649
  output.admin.rejectSupported = output.admin.canManageUsers;
@@ -1256,11 +1701,123 @@ export class ViewerServer {
1256
1701
  });
1257
1702
  }
1258
1703
 
1704
+ private handleSharingChangeRole(req: http.IncomingMessage, res: http.ServerResponse): void {
1705
+ this.readBody(req, async (body) => {
1706
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1707
+ try {
1708
+ const parsed = JSON.parse(body || "{}");
1709
+ const hub = this.resolveHubConnection();
1710
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1711
+ const result = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/change-role", {
1712
+ method: "POST",
1713
+ body: JSON.stringify({ userId: parsed.userId, role: parsed.role }),
1714
+ });
1715
+ this.jsonResponse(res, { ok: true, result });
1716
+ } catch (err) {
1717
+ this.jsonResponse(res, { ok: false, error: String(err) });
1718
+ }
1719
+ });
1720
+ }
1721
+
1722
+ private handleSharingRemoveUser(req: http.IncomingMessage, res: http.ServerResponse): void {
1723
+ this.readBody(req, async (body) => {
1724
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1725
+ try {
1726
+ const parsed = JSON.parse(body || "{}");
1727
+ const hub = this.resolveHubConnection();
1728
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1729
+ const result = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/remove-user", {
1730
+ method: "POST",
1731
+ body: JSON.stringify({ userId: parsed.userId, cleanResources: parsed.cleanResources === true }),
1732
+ });
1733
+ this.jsonResponse(res, { ok: true, result });
1734
+ } catch (err) {
1735
+ this.jsonResponse(res, { ok: false, error: String(err) });
1736
+ }
1737
+ });
1738
+ }
1739
+
1740
+ private handleAdminRenameUser(req: http.IncomingMessage, res: http.ServerResponse): void {
1741
+ this.readBody(req, async (body) => {
1742
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1743
+ try {
1744
+ const parsed = JSON.parse(body || "{}");
1745
+ const hub = this.resolveHubConnection();
1746
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1747
+ const result = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/rename-user", {
1748
+ method: "POST",
1749
+ body: JSON.stringify({ userId: parsed.userId, username: parsed.username }),
1750
+ });
1751
+ this.jsonResponse(res, { ok: true, result });
1752
+ } catch (err) {
1753
+ const errStr = String(err);
1754
+ if (errStr.includes("username_taken")) {
1755
+ this.jsonResponse(res, { ok: false, error: "username_taken" });
1756
+ } else if (errStr.includes("invalid_params")) {
1757
+ this.jsonResponse(res, { ok: false, error: "invalid_params" });
1758
+ } else {
1759
+ this.jsonResponse(res, { ok: false, error: errStr });
1760
+ }
1761
+ }
1762
+ });
1763
+ }
1764
+
1765
+ private handleRetryJoin(req: http.IncomingMessage, res: http.ServerResponse): void {
1766
+ this.readBody(req, async (_body) => {
1767
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1768
+ const sharing = this.ctx.config.sharing;
1769
+ if (!sharing?.enabled || sharing.role !== "client") {
1770
+ return this.jsonResponse(res, { ok: false, error: "not_in_client_mode" });
1771
+ }
1772
+ const hubAddress = sharing.client?.hubAddress ?? "";
1773
+ const teamToken = sharing.client?.teamToken ?? "";
1774
+ if (!hubAddress || !teamToken) {
1775
+ return this.jsonResponse(res, { ok: false, error: "missing_hub_address_or_team_token" });
1776
+ }
1777
+ try {
1778
+ const hubUrl = normalizeHubUrl(hubAddress);
1779
+ const localIPs = this.getLocalIPs();
1780
+ localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
1781
+ try {
1782
+ const u = new URL(hubUrl);
1783
+ if (localIPs.includes(u.hostname)) {
1784
+ return this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
1785
+ }
1786
+ } catch {}
1787
+ const os = await import("os");
1788
+ const nickname = sharing.client?.nickname;
1789
+ const username = nickname || os.userInfo().username || "user";
1790
+ const hostname = os.hostname() || "unknown";
1791
+ const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
1792
+ method: "POST",
1793
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true }),
1794
+ }) as any;
1795
+ this.store.setClientHubConnection({
1796
+ hubUrl,
1797
+ userId: String(result.userId || ""),
1798
+ username,
1799
+ userToken: result.userToken || "",
1800
+ role: "member",
1801
+ connectedAt: Date.now(),
1802
+ });
1803
+ this.jsonResponse(res, { ok: true, status: result.status || "pending" });
1804
+ } catch (err) {
1805
+ this.jsonResponse(res, { ok: false, error: String(err) });
1806
+ }
1807
+ });
1808
+ }
1809
+
1259
1810
  private async serveSharingMemoryList(res: http.ServerResponse, url: URL): Promise<void> {
1260
1811
  if (!this.ctx) return this.jsonResponse(res, { memories: [], error: "sharing_unavailable" });
1261
1812
  try {
1262
1813
  const limit = Number(url.searchParams.get("limit") || 40);
1263
- const data = await hubListMemories(this.store, this.ctx, { limit });
1814
+ const hub = this.resolveHubConnection();
1815
+ let data: any;
1816
+ if (hub) {
1817
+ data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/memories?limit=${limit}`);
1818
+ } else {
1819
+ data = await hubListMemories(this.store, this.ctx, { limit });
1820
+ }
1264
1821
  this.jsonResponse(res, { memories: Array.isArray(data?.memories) ? data.memories : [] });
1265
1822
  } catch (err) {
1266
1823
  this.jsonResponse(res, { memories: [], error: String(err) });
@@ -1271,7 +1828,13 @@ export class ViewerServer {
1271
1828
  if (!this.ctx) return this.jsonResponse(res, { tasks: [], error: "sharing_unavailable" });
1272
1829
  try {
1273
1830
  const limit = Number(url.searchParams.get("limit") || 40);
1274
- const data = await hubListTasks(this.store, this.ctx, { limit });
1831
+ const hub = this.resolveHubConnection();
1832
+ let data: any;
1833
+ if (hub) {
1834
+ data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/tasks?limit=${limit}`);
1835
+ } else {
1836
+ data = await hubListTasks(this.store, this.ctx, { limit });
1837
+ }
1275
1838
  this.jsonResponse(res, { tasks: Array.isArray(data?.tasks) ? data.tasks : [] });
1276
1839
  } catch (err) {
1277
1840
  this.jsonResponse(res, { tasks: [], error: String(err) });
@@ -1282,7 +1845,13 @@ export class ViewerServer {
1282
1845
  if (!this.ctx) return this.jsonResponse(res, { skills: [], error: "sharing_unavailable" });
1283
1846
  try {
1284
1847
  const limit = Number(url.searchParams.get("limit") || 40);
1285
- const data = await hubListSkills(this.store, this.ctx, { limit });
1848
+ const hub = this.resolveHubConnection();
1849
+ let data: any;
1850
+ if (hub) {
1851
+ data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/skills/list?limit=${limit}`);
1852
+ } else {
1853
+ data = await hubListSkills(this.store, this.ctx, { limit });
1854
+ }
1286
1855
  this.jsonResponse(res, { skills: Array.isArray(data?.skills) ? data.skills : [] });
1287
1856
  } catch (err) {
1288
1857
  this.jsonResponse(res, { skills: [], error: String(err) });
@@ -1298,13 +1867,21 @@ export class ViewerServer {
1298
1867
  const query = String(parsed.query || "");
1299
1868
  const role = typeof parsed.role === "string" ? parsed.role : undefined;
1300
1869
  const maxResults = typeof parsed.maxResults === "number" ? parsed.maxResults : 10;
1301
- const scope = parsed.scope === "group" || parsed.scope === "all" ? parsed.scope : "local";
1870
+ const scope = parsed.scope === "group" || parsed.scope === "all" || parsed.scope === "hub" ? (parsed.scope === "hub" ? "all" : parsed.scope) : "local";
1302
1871
  const local = this.searchLocalViewerMemories(query, { role, maxResults });
1303
1872
  if (scope === "local") {
1304
1873
  return this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub: emptyHub });
1305
1874
  }
1306
1875
  try {
1307
- const hub = await hubSearchMemories(this.store, this.ctx, { query, maxResults, scope });
1876
+ const conn = this.resolveHubConnection();
1877
+ let hub: any;
1878
+ if (conn) {
1879
+ hub = await hubRequestJson(conn.hubUrl, conn.userToken, "/api/v1/hub/search", {
1880
+ method: "POST", body: JSON.stringify({ query, maxResults, scope }),
1881
+ });
1882
+ } else {
1883
+ hub = await hubSearchMemories(this.store, this.ctx!, { query, maxResults, scope });
1884
+ }
1308
1885
  this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub });
1309
1886
  } catch (err) {
1310
1887
  this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub: emptyHub, error: String(err) });
@@ -1395,8 +1972,8 @@ export class ViewerServer {
1395
1972
  try {
1396
1973
  const parsed = JSON.parse(body || "{}");
1397
1974
  const taskId = String(parsed.taskId || "");
1398
- const visibility = parsed.visibility === "group" ? "group" : "public";
1399
- const groupId = typeof parsed.groupId === "string" ? parsed.groupId : undefined;
1975
+ const visibility = "public";
1976
+ const groupId: string | undefined = undefined;
1400
1977
  const task = this.store.getTask(taskId);
1401
1978
  if (!task) return this.jsonResponse(res, { ok: false, error: "task_not_found" });
1402
1979
  const chunks = this.store.getChunksByTask(taskId);
@@ -1410,7 +1987,7 @@ export class ViewerServer {
1410
1987
  sourceTaskId: task.id,
1411
1988
  title: task.title,
1412
1989
  summary: task.summary,
1413
- groupId: visibility === "group" ? groupId ?? null : null,
1990
+ groupId: null,
1414
1991
  visibility,
1415
1992
  createdAt: task.startedAt ?? Date.now(),
1416
1993
  updatedAt: task.updatedAt ?? Date.now(),
@@ -1436,7 +2013,7 @@ export class ViewerServer {
1436
2013
  sourceUserId: hubUserId,
1437
2014
  title: task.title,
1438
2015
  summary: task.summary,
1439
- groupId: visibility === "group" ? groupId ?? null : null,
2016
+ groupId: null,
1440
2017
  visibility,
1441
2018
  createdAt: task.startedAt ?? Date.now(),
1442
2019
  updatedAt: task.updatedAt ?? Date.now(),
@@ -1477,8 +2054,8 @@ export class ViewerServer {
1477
2054
  try {
1478
2055
  const parsed = JSON.parse(body || "{}");
1479
2056
  const chunkId = String(parsed.chunkId || "");
1480
- const visibility = parsed.visibility === "group" ? "group" : "public";
1481
- const groupId = typeof parsed.groupId === "string" ? parsed.groupId : undefined;
2057
+ const visibility = "public";
2058
+ const groupId: string | undefined = undefined;
1482
2059
  const db = (this.store as any).db;
1483
2060
  const chunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId) as any;
1484
2061
  if (!chunk) return this.jsonResponse(res, { ok: false, error: "memory_not_found" });
@@ -1492,7 +2069,7 @@ export class ViewerServer {
1492
2069
  content: chunk.content,
1493
2070
  summary: chunk.summary,
1494
2071
  kind: chunk.kind,
1495
- groupId: visibility === "group" ? groupId ?? null : null,
2072
+ groupId: null,
1496
2073
  visibility,
1497
2074
  },
1498
2075
  }),
@@ -1509,7 +2086,7 @@ export class ViewerServer {
1509
2086
  content: chunk.content,
1510
2087
  summary: chunk.summary ?? "",
1511
2088
  kind: chunk.kind,
1512
- groupId: visibility === "group" ? groupId ?? null : null,
2089
+ groupId: null,
1513
2090
  visibility,
1514
2091
  createdAt: existing?.createdAt ?? now,
1515
2092
  updatedAt: now,
@@ -1563,8 +2140,8 @@ export class ViewerServer {
1563
2140
  try {
1564
2141
  const parsed = JSON.parse(body || "{}");
1565
2142
  const skillId = String(parsed.skillId || "");
1566
- const visibility = parsed.visibility === "group" ? "group" : "public";
1567
- const groupId = parsed.groupId ? String(parsed.groupId) : null;
2143
+ const visibility = "public";
2144
+ const groupId: string | null = null;
1568
2145
  const skill = this.store.getSkill(skillId);
1569
2146
  if (!skill) return this.jsonResponse(res, { ok: false, error: "skill_not_found" });
1570
2147
  const bundle = buildSkillBundleForHub(this.store, skillId);
@@ -1573,7 +2150,7 @@ export class ViewerServer {
1573
2150
  method: "POST",
1574
2151
  body: JSON.stringify({
1575
2152
  visibility,
1576
- groupId: visibility === "group" ? groupId : null,
2153
+ groupId: null,
1577
2154
  metadata: bundle.metadata,
1578
2155
  bundle: bundle.bundle,
1579
2156
  }),
@@ -1588,7 +2165,7 @@ export class ViewerServer {
1588
2165
  name: skill.name,
1589
2166
  description: skill.description,
1590
2167
  version: skill.version,
1591
- groupId: visibility === "group" ? groupId : null,
2168
+ groupId: null,
1592
2169
  visibility,
1593
2170
  bundle: JSON.stringify(bundle.bundle),
1594
2171
  qualityScore: skill.qualityScore,
@@ -1671,117 +2248,6 @@ export class ViewerServer {
1671
2248
  return resolveHubClient(this.store, this.ctx);
1672
2249
  }
1673
2250
 
1674
- private extractGroupId(path: string): string {
1675
- const m = path.match(/\/api\/sharing\/groups\/([^/]+)/);
1676
- return m ? decodeURIComponent(m[1]) : "";
1677
- }
1678
-
1679
- private async serveSharingGroups(res: http.ServerResponse): Promise<void> {
1680
- const hub = this.resolveHubConnection();
1681
- if (!hub) return this.jsonResponse(res, { groups: [], error: "not_configured" });
1682
- try {
1683
- const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/groups", { method: "GET" }) as any;
1684
- this.jsonResponse(res, { groups: Array.isArray(data?.groups) ? data.groups : [] });
1685
- } catch (err) {
1686
- this.jsonResponse(res, { groups: [], error: String(err) });
1687
- }
1688
- }
1689
-
1690
- private handleSharingGroupCreate(req: http.IncomingMessage, res: http.ServerResponse): void {
1691
- this.readBody(req, async (body) => {
1692
- const hub = this.resolveHubConnection();
1693
- if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1694
- try {
1695
- const parsed = JSON.parse(body || "{}");
1696
- const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/groups", {
1697
- method: "POST",
1698
- body: JSON.stringify({ name: parsed.name, description: parsed.description }),
1699
- }) as any;
1700
- this.jsonResponse(res, { ok: true, ...data });
1701
- } catch (err) {
1702
- this.jsonResponse(res, { ok: false, error: String(err) });
1703
- }
1704
- });
1705
- }
1706
-
1707
- private handleSharingGroupUpdate(req: http.IncomingMessage, res: http.ServerResponse, p: string): void {
1708
- this.readBody(req, async (body) => {
1709
- const hub = this.resolveHubConnection();
1710
- if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1711
- const groupId = this.extractGroupId(p);
1712
- try {
1713
- const parsed = JSON.parse(body || "{}");
1714
- await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}`, {
1715
- method: "PUT",
1716
- body: JSON.stringify({ name: parsed.name, description: parsed.description }),
1717
- });
1718
- this.jsonResponse(res, { ok: true });
1719
- } catch (err) {
1720
- this.jsonResponse(res, { ok: false, error: String(err) });
1721
- }
1722
- });
1723
- }
1724
-
1725
- private async handleSharingGroupDelete(res: http.ServerResponse, p: string): Promise<void> {
1726
- const hub = this.resolveHubConnection();
1727
- if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1728
- const groupId = this.extractGroupId(p);
1729
- try {
1730
- await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}`, { method: "DELETE" });
1731
- this.jsonResponse(res, { ok: true });
1732
- } catch (err) {
1733
- this.jsonResponse(res, { ok: false, error: String(err) });
1734
- }
1735
- }
1736
-
1737
- private async serveSharingGroupMembers(res: http.ServerResponse, p: string): Promise<void> {
1738
- const hub = this.resolveHubConnection();
1739
- if (!hub) return this.jsonResponse(res, { members: [], error: "not_configured" });
1740
- const groupId = this.extractGroupId(p);
1741
- try {
1742
- const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}`, { method: "GET" }) as any;
1743
- this.jsonResponse(res, { members: Array.isArray(data?.members) ? data.members : [] });
1744
- } catch (err) {
1745
- this.jsonResponse(res, { members: [], error: String(err) });
1746
- }
1747
- }
1748
-
1749
- private handleSharingGroupAddMember(req: http.IncomingMessage, res: http.ServerResponse, p: string): void {
1750
- this.readBody(req, async (body) => {
1751
- const hub = this.resolveHubConnection();
1752
- if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1753
- const groupId = this.extractGroupId(p);
1754
- try {
1755
- const parsed = JSON.parse(body || "{}");
1756
- await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}/members`, {
1757
- method: "POST",
1758
- body: JSON.stringify({ userId: parsed.userId }),
1759
- });
1760
- this.jsonResponse(res, { ok: true });
1761
- } catch (err) {
1762
- this.jsonResponse(res, { ok: false, error: String(err) });
1763
- }
1764
- });
1765
- }
1766
-
1767
- private handleSharingGroupRemoveMember(req: http.IncomingMessage, res: http.ServerResponse, p: string): void {
1768
- this.readBody(req, async (body) => {
1769
- const hub = this.resolveHubConnection();
1770
- if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
1771
- const groupId = this.extractGroupId(p);
1772
- try {
1773
- const parsed = JSON.parse(body || "{}");
1774
- await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}/members`, {
1775
- method: "DELETE",
1776
- body: JSON.stringify({ userId: parsed.userId }),
1777
- });
1778
- this.jsonResponse(res, { ok: true });
1779
- } catch (err) {
1780
- this.jsonResponse(res, { ok: false, error: String(err) });
1781
- }
1782
- });
1783
- }
1784
-
1785
2251
  private async serveSharingUsers(res: http.ServerResponse): Promise<void> {
1786
2252
  const hub = this.resolveHubConnection();
1787
2253
  if (!hub) return this.jsonResponse(res, { users: [], error: "not_configured" });
@@ -1800,7 +2266,14 @@ export class ViewerServer {
1800
2266
  if (!hub) return this.jsonResponse(res, { tasks: [], error: "not_configured" });
1801
2267
  try {
1802
2268
  const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/shared-tasks", { method: "GET" }) as any;
1803
- this.jsonResponse(res, { tasks: Array.isArray(data?.tasks) ? data.tasks : [] });
2269
+ const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
2270
+ for (const tk of tasks) {
2271
+ if (!tk.summary && tk.sourceTaskId) {
2272
+ const local = this.store.getTask(tk.sourceTaskId);
2273
+ if (local) { tk.summary = local.summary; tk.title = tk.title || local.title; }
2274
+ }
2275
+ }
2276
+ this.jsonResponse(res, { tasks });
1804
2277
  } catch (err) {
1805
2278
  this.jsonResponse(res, { tasks: [], error: String(err) });
1806
2279
  }
@@ -1818,12 +2291,47 @@ export class ViewerServer {
1818
2291
  }
1819
2292
  }
1820
2293
 
2294
+ private async serveHubTaskDetail(res: http.ServerResponse, p: string): Promise<void> {
2295
+ const hub = this.resolveHubConnection();
2296
+ if (!hub) return this.jsonResponse(res, { error: "not_configured" }, 500);
2297
+ const m = p.match(/^\/api\/admin\/shared-tasks\/([^/]+)\/detail$/);
2298
+ if (!m) return this.jsonResponse(res, { error: "bad_request" }, 400);
2299
+ const taskId = decodeURIComponent(m[1]);
2300
+ try {
2301
+ const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-tasks/${encodeURIComponent(taskId)}/detail`, { method: "GET" }) as any;
2302
+ this.jsonResponse(res, data);
2303
+ } catch (err) {
2304
+ this.jsonResponse(res, { error: String(err) }, 500);
2305
+ }
2306
+ }
2307
+
2308
+ private async serveHubSkillDetail(res: http.ServerResponse, p: string): Promise<void> {
2309
+ const hub = this.resolveHubConnection();
2310
+ if (!hub) return this.jsonResponse(res, { error: "not_configured" }, 500);
2311
+ const m = p.match(/^\/api\/admin\/shared-skills\/([^/]+)\/detail$/);
2312
+ if (!m) return this.jsonResponse(res, { error: "bad_request" }, 400);
2313
+ const skillId = decodeURIComponent(m[1]);
2314
+ try {
2315
+ const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-skills/${encodeURIComponent(skillId)}/detail`, { method: "GET" }) as any;
2316
+ this.jsonResponse(res, data);
2317
+ } catch (err) {
2318
+ this.jsonResponse(res, { error: String(err) }, 500);
2319
+ }
2320
+ }
2321
+
1821
2322
  private async serveAdminSharedSkills(res: http.ServerResponse): Promise<void> {
1822
2323
  const hub = this.resolveHubConnection();
1823
2324
  if (!hub) return this.jsonResponse(res, { skills: [], error: "not_configured" });
1824
2325
  try {
1825
2326
  const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/shared-skills", { method: "GET" }) as any;
1826
- this.jsonResponse(res, { skills: Array.isArray(data?.skills) ? data.skills : [] });
2327
+ const skills = Array.isArray(data?.skills) ? data.skills : [];
2328
+ for (const sk of skills) {
2329
+ if (!sk.description && sk.sourceSkillId) {
2330
+ const local = this.store.getSkill(sk.sourceSkillId);
2331
+ if (local) { sk.description = sk.description || local.description; sk.name = sk.name || local.name; }
2332
+ }
2333
+ }
2334
+ this.jsonResponse(res, { skills });
1827
2335
  } catch (err) {
1828
2336
  this.jsonResponse(res, { skills: [], error: String(err) });
1829
2337
  }
@@ -1846,7 +2354,14 @@ export class ViewerServer {
1846
2354
  if (!hub) return this.jsonResponse(res, { memories: [], error: "not_configured" });
1847
2355
  try {
1848
2356
  const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/shared-memories", { method: "GET" }) as any;
1849
- this.jsonResponse(res, { memories: Array.isArray(data?.memories) ? data.memories : [] });
2357
+ const memories = Array.isArray(data?.memories) ? data.memories : [];
2358
+ for (const m of memories) {
2359
+ if (!m.content && m.sourceChunkId) {
2360
+ const local = this.store.getChunk(m.sourceChunkId);
2361
+ if (local) { m.content = local.content; if (!m.summary && local.summary) m.summary = local.summary; }
2362
+ }
2363
+ }
2364
+ this.jsonResponse(res, { memories });
1850
2365
  } catch (err) {
1851
2366
  this.jsonResponse(res, { memories: [], error: String(err) });
1852
2367
  }
@@ -1864,7 +2379,121 @@ export class ViewerServer {
1864
2379
  }
1865
2380
  }
1866
2381
 
1867
- private serveLocalIPs(res: http.ServerResponse): void {
2382
+ private async serveSharingNotifications(res: http.ServerResponse, url: URL): Promise<void> {
2383
+ const hub = this.resolveHubConnection();
2384
+ if (!hub) return this.jsonResponse(res, { notifications: [], unreadCount: 0 });
2385
+ try {
2386
+ const unread = url.searchParams.get("unread") === "1" ? "?unread=1" : "";
2387
+ const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/notifications${unread}`) as any;
2388
+ this.jsonResponse(res, data);
2389
+ } catch {
2390
+ this.jsonResponse(res, { notifications: [], unreadCount: 0 });
2391
+ }
2392
+ }
2393
+
2394
+ private handleSharingNotificationsRead(req: http.IncomingMessage, res: http.ServerResponse): void {
2395
+ const hub = this.resolveHubConnection();
2396
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
2397
+ this.readBody(req, async (raw) => {
2398
+ try {
2399
+ const body = JSON.parse(raw || "{}");
2400
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/read", { method: "POST", body: JSON.stringify(body) });
2401
+ this.jsonResponse(res, { ok: true });
2402
+ try {
2403
+ const data = (await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")) as any;
2404
+ const count = data?.unreadCount ?? 0;
2405
+ this.lastKnownNotifCount = count;
2406
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2407
+ } catch { /* best effort */ }
2408
+ } catch (err) {
2409
+ this.jsonResponse(res, { ok: false, error: String(err) });
2410
+ }
2411
+ });
2412
+ }
2413
+
2414
+ private handleSharingNotificationsClear(req: http.IncomingMessage, res: http.ServerResponse): void {
2415
+ const hub = this.resolveHubConnection();
2416
+ if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
2417
+ this.readBody(req, async () => {
2418
+ try {
2419
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/clear", { method: "POST", body: "{}" });
2420
+ this.jsonResponse(res, { ok: true });
2421
+ this.broadcastNotifSSE({ type: "cleared", unreadCount: 0 });
2422
+ } catch (err) {
2423
+ this.jsonResponse(res, { ok: false, error: String(err) });
2424
+ }
2425
+ });
2426
+ }
2427
+
2428
+ private handleNotifSSE(req: http.IncomingMessage, res: http.ServerResponse): void {
2429
+ res.writeHead(200, {
2430
+ "Content-Type": "text/event-stream",
2431
+ "Cache-Control": "no-cache",
2432
+ Connection: "keep-alive",
2433
+ "Access-Control-Allow-Origin": "*",
2434
+ });
2435
+ res.write("data: {\"type\":\"connected\"}\n\n");
2436
+ this.notifSSEClients.push(res);
2437
+ if (!this.notifPollTimer) this.startNotifPoll();
2438
+ req.on("close", () => {
2439
+ this.notifSSEClients = this.notifSSEClients.filter((c) => c !== res);
2440
+ if (this.notifSSEClients.length === 0) this.stopNotifPoll();
2441
+ });
2442
+ }
2443
+
2444
+ private broadcastNotifSSE(data: Record<string, unknown>): void {
2445
+ const msg = `data: ${JSON.stringify(data)}\n\n`;
2446
+ this.notifSSEClients = this.notifSSEClients.filter((c) => {
2447
+ try { c.write(msg); return true; } catch { return false; }
2448
+ });
2449
+ }
2450
+
2451
+ private startNotifPoll(): void {
2452
+ this.stopNotifPoll();
2453
+ const tick = async () => {
2454
+ const hub = this.resolveHubConnection();
2455
+ if (!hub) return;
2456
+ try {
2457
+ const data = (await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")) as any;
2458
+ const count = data?.unreadCount ?? 0;
2459
+ if (count !== this.lastKnownNotifCount) {
2460
+ this.lastKnownNotifCount = count;
2461
+ this.broadcastNotifSSE({ type: "update", unreadCount: count });
2462
+ }
2463
+ } catch { /* ignore */ }
2464
+ };
2465
+ tick();
2466
+ this.notifPollTimer = setInterval(tick, 3000);
2467
+ }
2468
+
2469
+ private stopNotifPoll(): void {
2470
+ if (this.notifPollTimer) { clearInterval(this.notifPollTimer); this.notifPollTimer = undefined; }
2471
+ }
2472
+
2473
+ private startHubHeartbeat(): void {
2474
+ this.stopHubHeartbeat();
2475
+ const sendHeartbeat = async () => {
2476
+ try {
2477
+ const hub = this.resolveHubConnection();
2478
+ if (!hub) {
2479
+ const persisted = this.store.getClientHubConnection();
2480
+ if (persisted?.hubUrl && persisted?.userToken) {
2481
+ await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
2482
+ }
2483
+ return;
2484
+ }
2485
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
2486
+ } catch { /* best-effort */ }
2487
+ };
2488
+ sendHeartbeat();
2489
+ this.hubHeartbeatTimer = setInterval(sendHeartbeat, ViewerServer.HUB_HEARTBEAT_INTERVAL_MS);
2490
+ }
2491
+
2492
+ private stopHubHeartbeat(): void {
2493
+ if (this.hubHeartbeatTimer) { clearInterval(this.hubHeartbeatTimer); this.hubHeartbeatTimer = undefined; }
2494
+ }
2495
+
2496
+ private getLocalIPs(): string[] {
1868
2497
  const nets = os.networkInterfaces();
1869
2498
  const ips: string[] = [];
1870
2499
  for (const name of Object.keys(nets)) {
@@ -1874,6 +2503,11 @@ export class ViewerServer {
1874
2503
  }
1875
2504
  }
1876
2505
  }
2506
+ return ips;
2507
+ }
2508
+
2509
+ private serveLocalIPs(res: http.ServerResponse): void {
2510
+ const ips = this.getLocalIPs();
1877
2511
  res.writeHead(200, { "Content-Type": "application/json" });
1878
2512
  res.end(JSON.stringify({ ips }));
1879
2513
  }
@@ -1911,7 +2545,7 @@ export class ViewerServer {
1911
2545
  }
1912
2546
 
1913
2547
  private handleSaveConfig(req: http.IncomingMessage, res: http.ServerResponse): void {
1914
- this.readBody(req, (body) => {
2548
+ this.readBody(req, async (body) => {
1915
2549
  try {
1916
2550
  const newCfg = JSON.parse(body);
1917
2551
  const cfgPath = this.getOpenClawConfigPath();
@@ -1934,6 +2568,11 @@ export class ViewerServer {
1934
2568
  if (!entry.config) entry.config = {};
1935
2569
  const config = entry.config as Record<string, unknown>;
1936
2570
 
2571
+ const oldSharing = config.sharing as Record<string, unknown> | undefined;
2572
+ const oldSharingRole = oldSharing?.role as string | undefined;
2573
+ const oldSharingEnabled = Boolean(oldSharing?.enabled);
2574
+ const oldClientHubAddress = String((oldSharing?.client as Record<string, unknown>)?.hubAddress || "");
2575
+
1937
2576
  if (newCfg.embedding) config.embedding = newCfg.embedding;
1938
2577
  if (newCfg.summarizer) config.summarizer = newCfg.summarizer;
1939
2578
  if (newCfg.skillEvolution) config.skillEvolution = newCfg.skillEvolution;
@@ -1942,16 +2581,76 @@ export class ViewerServer {
1942
2581
  if (newCfg.sharing !== undefined) {
1943
2582
  const existing = (config.sharing as Record<string, unknown>) || {};
1944
2583
  const merged = { ...existing, ...newCfg.sharing };
1945
- // Deep-merge capabilities so new keys don't wipe existing ones
1946
2584
  if (newCfg.sharing.capabilities && existing.capabilities) {
1947
2585
  merged.capabilities = { ...(existing.capabilities as Record<string, unknown>), ...newCfg.sharing.capabilities };
1948
2586
  }
2587
+ if (merged.role === "client" && merged.client) {
2588
+ const clientCfg = merged.client as Record<string, unknown>;
2589
+ const addr = String(clientCfg.hubAddress || "");
2590
+ if (addr) {
2591
+ const localIPs = this.getLocalIPs();
2592
+ localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2593
+ try {
2594
+ const u = new URL(addr.startsWith("http") ? addr : `http://${addr}`);
2595
+ if (localIPs.includes(u.hostname)) {
2596
+ res.writeHead(400, { "Content-Type": "application/json" });
2597
+ res.end(JSON.stringify({ error: "cannot_join_self" }));
2598
+ return;
2599
+ }
2600
+ } catch {}
2601
+ }
2602
+ }
2603
+
2604
+ const newRole = merged.role as string | undefined;
2605
+ const newEnabled = Boolean(merged.enabled);
2606
+
2607
+ // Detect disabling sharing or switching away from hub mode
2608
+ const wasHub = oldSharingEnabled && oldSharingRole === "hub";
2609
+ const isHub = newEnabled && newRole === "hub";
2610
+ if (wasHub && !isHub) {
2611
+ await this.notifyHubShutdown();
2612
+ this.stopHubHeartbeat();
2613
+ this.log.info("Hub shutting down: notified connected clients");
2614
+ }
2615
+
2616
+ // Detect disabling sharing or switching away from client mode
2617
+ const wasClient = oldSharingEnabled && oldSharingRole === "client";
2618
+ const isClient = newEnabled && newRole === "client";
2619
+ if (wasClient && !isClient) {
2620
+ this.notifyHubLeave();
2621
+ this.store.clearClientHubConnection();
2622
+ this.log.info("Cleared client hub connection (sharing disabled or role changed)");
2623
+ }
2624
+
2625
+ // Detect switching to a different Hub while still in client mode
2626
+ if (wasClient && isClient) {
2627
+ const newClientAddr = String((merged.client as Record<string, unknown>)?.hubAddress || "");
2628
+ if (newClientAddr && oldClientHubAddress && normalizeHubUrl(newClientAddr) !== normalizeHubUrl(oldClientHubAddress)) {
2629
+ this.notifyHubLeave();
2630
+ this.store.clearClientHubConnection();
2631
+ this.log.info("Cleared client hub connection (switched to different Hub)");
2632
+ }
2633
+ }
2634
+
2635
+ if (merged.role === "hub") {
2636
+ merged.client = { hubAddress: "", userToken: "", teamToken: "" };
2637
+ } else if (merged.role === "client") {
2638
+ merged.hub = { port: 18800, teamName: "", teamToken: "" };
2639
+ }
1949
2640
  config.sharing = merged;
1950
2641
  }
1951
2642
 
1952
2643
  fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
1953
2644
  fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2), "utf-8");
1954
2645
  this.log.info("Plugin config updated via Viewer");
2646
+ this.stopHubHeartbeat();
2647
+
2648
+ // When switching to client mode, immediately send join request
2649
+ const finalSharing = config.sharing as Record<string, unknown> | undefined;
2650
+ if (finalSharing?.role === "client" && oldSharingRole !== "client") {
2651
+ this.autoJoinOnSave(finalSharing).catch(e => this.log.warn(`Auto-join on save failed: ${e}`));
2652
+ }
2653
+
1955
2654
  this.jsonResponse(res, { ok: true });
1956
2655
  } catch (e) {
1957
2656
  this.log.warn(`handleSaveConfig error: ${e}`);
@@ -1961,6 +2660,87 @@ export class ViewerServer {
1961
2660
  });
1962
2661
  }
1963
2662
 
2663
+ private async autoJoinOnSave(sharing: Record<string, unknown>): Promise<void> {
2664
+ const clientCfg = sharing.client as Record<string, unknown> | undefined;
2665
+ const hubAddress = String(clientCfg?.hubAddress || "");
2666
+ const teamToken = String(clientCfg?.teamToken || "");
2667
+ if (!hubAddress || !teamToken) return;
2668
+ const hubUrl = normalizeHubUrl(hubAddress);
2669
+ const os = await import("os");
2670
+ const nickname = String(clientCfg?.nickname || "");
2671
+ const username = nickname || os.userInfo().username || "user";
2672
+ const hostname = os.hostname() || "unknown";
2673
+ const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
2674
+ method: "POST",
2675
+ body: JSON.stringify({ teamToken, username, deviceName: hostname }),
2676
+ }) as any;
2677
+ this.store.setClientHubConnection({
2678
+ hubUrl,
2679
+ userId: String(result.userId || ""),
2680
+ username,
2681
+ userToken: result.userToken || "",
2682
+ role: "member",
2683
+ connectedAt: Date.now(),
2684
+ });
2685
+ this.log.info(`Auto-join on save: status=${result.status}, userId=${result.userId}`);
2686
+ if (result.userToken) {
2687
+ this.startHubHeartbeat();
2688
+ }
2689
+ }
2690
+
2691
+ private async notifyHubLeave(): Promise<void> {
2692
+ try {
2693
+ const hub = this.resolveHubConnection();
2694
+ if (hub) {
2695
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
2696
+ this.log.info("Notified Hub of voluntary leave");
2697
+ return;
2698
+ }
2699
+ const persisted = this.store.getClientHubConnection();
2700
+ if (persisted?.hubUrl && persisted?.userToken) {
2701
+ await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
2702
+ this.log.info("Notified Hub of voluntary leave (persisted connection)");
2703
+ }
2704
+ } catch (e) {
2705
+ this.log.warn(`Failed to notify Hub of leave: ${e}`);
2706
+ }
2707
+ }
2708
+
2709
+ private async notifyHubShutdown(): Promise<void> {
2710
+ try {
2711
+ const sharing = this.ctx?.config.sharing;
2712
+ if (!sharing || sharing.role !== "hub") return;
2713
+ const hubPort = sharing.hub?.port ?? 18800;
2714
+ const authPath = path.join(this.dataDir, "hub-auth.json");
2715
+ let adminToken: string | undefined;
2716
+ try {
2717
+ const authData = JSON.parse(fs.readFileSync(authPath, "utf8"));
2718
+ adminToken = authData?.bootstrapAdminToken;
2719
+ } catch { return; }
2720
+ if (!adminToken) return;
2721
+
2722
+ const users = this.store.listHubUsers("active");
2723
+ const { v4: uuidv4 } = require("uuid");
2724
+ for (const u of users) {
2725
+ try {
2726
+ this.store.insertHubNotification({
2727
+ id: uuidv4(),
2728
+ userId: u.id,
2729
+ type: "hub_shutdown",
2730
+ resource: "hub",
2731
+ title: "Hub is shutting down",
2732
+ message: "The Hub server is shutting down. You may be disconnected.",
2733
+ });
2734
+ } catch (e) {
2735
+ this.log.warn(`Failed to insert shutdown notification for user ${u.id}: ${e}`);
2736
+ }
2737
+ }
2738
+ this.log.info(`Hub shutdown: notified ${users.length} approved user(s)`);
2739
+ } catch (e) {
2740
+ this.log.warn(`notifyHubShutdown error: ${e}`);
2741
+ }
2742
+ }
2743
+
1964
2744
  private handleUpdateUsername(req: http.IncomingMessage, res: http.ServerResponse): void {
1965
2745
  this.readBody(req, async (body) => {
1966
2746
  if (!this.ctx) return this.jsonResponse(res, { error: "sharing_unavailable" });
@@ -2014,6 +2794,15 @@ export class ViewerServer {
2014
2794
  try {
2015
2795
  const { hubUrl } = JSON.parse(body);
2016
2796
  if (!hubUrl) { this.jsonResponse(res, { ok: false, error: "hubUrl is required" }); return; }
2797
+ try {
2798
+ const localIPs = this.getLocalIPs();
2799
+ localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2800
+ const parsed = new URL(hubUrl.startsWith("http") ? hubUrl : `http://${hubUrl}`);
2801
+ if (localIPs.includes(parsed.hostname)) {
2802
+ this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
2803
+ return;
2804
+ }
2805
+ } catch {}
2017
2806
  const url = hubUrl.replace(/\/+$/, "") + "/api/v1/hub/info";
2018
2807
  const ctrl = new AbortController();
2019
2808
  const timeout = setTimeout(() => ctrl.abort(), 8000);
@@ -2405,7 +3194,7 @@ export class ViewerServer {
2405
3194
 
2406
3195
  private getOpenClawHome(): string {
2407
3196
  const home = process.env.HOME || process.env.USERPROFILE || "";
2408
- return path.join(home, ".openclaw");
3197
+ return process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
2409
3198
  }
2410
3199
 
2411
3200
  private handleCleanupPolluted(res: http.ServerResponse): void {
@@ -2434,7 +3223,7 @@ export class ViewerServer {
2434
3223
  try {
2435
3224
  const ocHome = this.getOpenClawHome();
2436
3225
  const memoryDir = path.join(ocHome, "memory");
2437
- const sessionsDir = path.join(ocHome, "agents", "main", "sessions");
3226
+ const agentsDir = path.join(ocHome, "agents");
2438
3227
 
2439
3228
  const sqliteFiles: Array<{ file: string; chunks: number }> = [];
2440
3229
  if (fs.existsSync(memoryDir)) {
@@ -2453,31 +3242,36 @@ export class ViewerServer {
2453
3242
 
2454
3243
  let sessionCount = 0;
2455
3244
  let messageCount = 0;
2456
- if (fs.existsSync(sessionsDir)) {
2457
- const jsonlFiles = fs.readdirSync(sessionsDir).filter(f => f.includes(".jsonl"));
2458
- sessionCount = jsonlFiles.length;
2459
- for (const f of jsonlFiles) {
2460
- try {
2461
- const content = fs.readFileSync(path.join(sessionsDir, f), "utf-8");
2462
- const lines = content.split("\n").filter(l => l.trim());
2463
- for (const line of lines) {
2464
- try {
2465
- const obj = JSON.parse(line);
2466
- if (obj.type === "message") {
2467
- const role = obj.message?.role ?? obj.role;
2468
- if (role === "user" || role === "assistant") {
2469
- const mc = obj.message?.content ?? obj.content;
2470
- let txt = "";
2471
- if (typeof mc === "string") txt = mc;
2472
- else if (Array.isArray(mc)) txt = mc.filter((p: any) => p.type === "text" && p.text).map((p: any) => p.text).join("\n");
2473
- else txt = JSON.stringify(mc);
2474
- if (role === "user") txt = stripInboundMetadata(txt);
2475
- if (txt && txt.length >= 10) messageCount++;
3245
+ if (fs.existsSync(agentsDir)) {
3246
+ for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
3247
+ if (!entry.isDirectory()) continue;
3248
+ const sessDir = path.join(agentsDir, entry.name, "sessions");
3249
+ if (!fs.existsSync(sessDir)) continue;
3250
+ const jsonlFiles = fs.readdirSync(sessDir).filter(f => f.includes(".jsonl"));
3251
+ sessionCount += jsonlFiles.length;
3252
+ for (const f of jsonlFiles) {
3253
+ try {
3254
+ const content = fs.readFileSync(path.join(sessDir, f), "utf-8");
3255
+ const lines = content.split("\n").filter(l => l.trim());
3256
+ for (const line of lines) {
3257
+ try {
3258
+ const obj = JSON.parse(line);
3259
+ if (obj.type === "message") {
3260
+ const role = obj.message?.role ?? obj.role;
3261
+ if (role === "user" || role === "assistant") {
3262
+ const mc = obj.message?.content ?? obj.content;
3263
+ let txt = "";
3264
+ if (typeof mc === "string") txt = mc;
3265
+ else if (Array.isArray(mc)) txt = mc.filter((p: any) => p.type === "text" && p.text).map((p: any) => p.text).join("\n");
3266
+ else txt = JSON.stringify(mc);
3267
+ if (role === "user") txt = stripInboundMetadata(txt);
3268
+ if (txt && txt.length >= 10) messageCount++;
3269
+ }
2476
3270
  }
2477
- }
2478
- } catch { /* skip bad lines */ }
2479
- }
2480
- } catch { /* skip unreadable */ }
3271
+ } catch { /* skip bad lines */ }
3272
+ }
3273
+ } catch { /* skip unreadable */ }
3274
+ }
2481
3275
  }
2482
3276
  }
2483
3277