@memtensor/memos-local-openclaw-plugin 1.0.5 → 1.0.6-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 (66) hide show
  1. package/dist/capture/index.d.ts.map +1 -1
  2. package/dist/capture/index.js +24 -0
  3. package/dist/capture/index.js.map +1 -1
  4. package/dist/client/connector.d.ts.map +1 -1
  5. package/dist/client/connector.js +33 -5
  6. package/dist/client/connector.js.map +1 -1
  7. package/dist/client/hub.d.ts.map +1 -1
  8. package/dist/client/hub.js +4 -0
  9. package/dist/client/hub.js.map +1 -1
  10. package/dist/hub/server.d.ts +2 -0
  11. package/dist/hub/server.d.ts.map +1 -1
  12. package/dist/hub/server.js +116 -54
  13. package/dist/hub/server.js.map +1 -1
  14. package/dist/ingest/providers/index.d.ts +4 -0
  15. package/dist/ingest/providers/index.d.ts.map +1 -1
  16. package/dist/ingest/providers/index.js +32 -86
  17. package/dist/ingest/providers/index.js.map +1 -1
  18. package/dist/ingest/providers/openai.d.ts.map +1 -1
  19. package/dist/ingest/providers/openai.js +29 -13
  20. package/dist/ingest/providers/openai.js.map +1 -1
  21. package/dist/recall/engine.d.ts.map +1 -1
  22. package/dist/recall/engine.js +33 -32
  23. package/dist/recall/engine.js.map +1 -1
  24. package/dist/storage/sqlite.d.ts +43 -7
  25. package/dist/storage/sqlite.d.ts.map +1 -1
  26. package/dist/storage/sqlite.js +179 -58
  27. package/dist/storage/sqlite.js.map +1 -1
  28. package/dist/tools/memory-get.d.ts.map +1 -1
  29. package/dist/tools/memory-get.js +4 -1
  30. package/dist/tools/memory-get.js.map +1 -1
  31. package/dist/types.d.ts +1 -1
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/types.js.map +1 -1
  34. package/dist/update-check.d.ts.map +1 -1
  35. package/dist/update-check.js +2 -7
  36. package/dist/update-check.js.map +1 -1
  37. package/dist/viewer/html.d.ts.map +1 -1
  38. package/dist/viewer/html.js +115 -27
  39. package/dist/viewer/html.js.map +1 -1
  40. package/dist/viewer/server.d.ts +25 -0
  41. package/dist/viewer/server.d.ts.map +1 -1
  42. package/dist/viewer/server.js +503 -206
  43. package/dist/viewer/server.js.map +1 -1
  44. package/index.ts +273 -282
  45. package/openclaw.plugin.json +1 -1
  46. package/package.json +2 -1
  47. package/scripts/native-binding.cjs +32 -0
  48. package/scripts/postinstall.cjs +24 -11
  49. package/src/capture/index.ts +36 -0
  50. package/src/client/connector.ts +32 -5
  51. package/src/client/hub.ts +4 -0
  52. package/src/hub/server.ts +110 -50
  53. package/src/ingest/providers/index.ts +37 -92
  54. package/src/ingest/providers/openai.ts +31 -13
  55. package/src/recall/engine.ts +32 -30
  56. package/src/storage/sqlite.ts +196 -63
  57. package/src/tools/memory-get.ts +4 -1
  58. package/src/types.ts +2 -0
  59. package/src/update-check.ts +2 -7
  60. package/src/viewer/html.ts +115 -27
  61. package/src/viewer/server.ts +483 -172
  62. package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
  63. package/prebuilds/darwin-x64/better_sqlite3.node +0 -0
  64. package/prebuilds/linux-x64/better_sqlite3.node +0 -0
  65. package/prebuilds/win32-x64/better_sqlite3.node +0 -0
  66. package/telemetry.credentials.json +0 -5
@@ -1,7 +1,7 @@
1
1
  import http from "node:http";
2
2
  import os from "node:os";
3
3
  import crypto from "node:crypto";
4
- import { execSync, exec } from "node:child_process";
4
+ import { execSync, exec, execFile } from "node:child_process";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import readline from "node:readline";
@@ -22,9 +22,72 @@ import type { Logger, Chunk, PluginContext, MemosLocalConfig } from "../types";
22
22
  import { viewerHTML } from "./html";
23
23
  import { v4 as uuid } from "uuid";
24
24
 
25
- function normalizeTimestamp(ts: number): number {
26
- if (ts < 1e12) return ts * 1000;
27
- return ts;
25
+ export interface MigrationStepFailureCounts {
26
+ summarization: number;
27
+ dedup: number;
28
+ embedding: number;
29
+ }
30
+
31
+ export interface MigrationStateSnapshot {
32
+ phase: string;
33
+ stored: number;
34
+ skipped: number;
35
+ merged: number;
36
+ errors: number;
37
+ processed: number;
38
+ total: number;
39
+ lastItem: any;
40
+ done: boolean;
41
+ stopped: boolean;
42
+ stepFailures: MigrationStepFailureCounts;
43
+ success: boolean;
44
+ }
45
+
46
+ function createInitialStepFailures(): MigrationStepFailureCounts {
47
+ return { summarization: 0, dedup: 0, embedding: 0 };
48
+ }
49
+
50
+ export function computeMigrationSuccess(state: Pick<MigrationStateSnapshot, "errors" | "stepFailures">): boolean {
51
+ const sf = state.stepFailures;
52
+ return state.errors === 0 && sf.summarization === 0 && sf.dedup === 0 && sf.embedding === 0;
53
+ }
54
+
55
+ export function createInitialMigrationState(): MigrationStateSnapshot {
56
+ const stepFailures = createInitialStepFailures();
57
+ return {
58
+ phase: "",
59
+ stored: 0,
60
+ skipped: 0,
61
+ merged: 0,
62
+ errors: 0,
63
+ processed: 0,
64
+ total: 0,
65
+ lastItem: null,
66
+ done: false,
67
+ stopped: false,
68
+ stepFailures,
69
+ success: computeMigrationSuccess({ errors: 0, stepFailures }),
70
+ };
71
+ }
72
+
73
+ export function applyMigrationItemToState(state: MigrationStateSnapshot, d: any): void {
74
+ if (d.status === "stored") state.stored++;
75
+ else if (d.status === "skipped" || d.status === "duplicate") state.skipped++;
76
+ else if (d.status === "merged") state.merged++;
77
+ else if (d.status === "error") state.errors++;
78
+
79
+ if (Array.isArray(d.stepFailures)) {
80
+ for (const step of d.stepFailures) {
81
+ if (step === "summarization") state.stepFailures.summarization++;
82
+ else if (step === "dedup") state.stepFailures.dedup++;
83
+ else if (step === "embedding") state.stepFailures.embedding++;
84
+ }
85
+ }
86
+
87
+ state.processed = d.index ?? state.processed + 1;
88
+ state.total = d.total ?? state.total;
89
+ state.lastItem = d;
90
+ state.success = computeMigrationSuccess(state);
28
91
  }
29
92
 
30
93
  export interface ViewerServerOptions {
@@ -67,18 +130,7 @@ export class ViewerServer {
67
130
  private resetToken: string;
68
131
  private migrationRunning = false;
69
132
  private migrationAbort = false;
70
- private migrationState: {
71
- phase: string;
72
- stored: number;
73
- skipped: number;
74
- merged: number;
75
- errors: number;
76
- processed: number;
77
- total: number;
78
- lastItem: any;
79
- done: boolean;
80
- stopped: boolean;
81
- } = { phase: "", stored: 0, skipped: 0, merged: 0, errors: 0, processed: 0, total: 0, lastItem: null, done: false, stopped: false };
133
+ private migrationState: MigrationStateSnapshot = createInitialMigrationState();
82
134
  private migrationSSEClients: http.ServerResponse[] = [];
83
135
 
84
136
  private ppRunning = false;
@@ -491,13 +543,12 @@ export class ViewerServer {
491
543
  if (chunkIds.length > 0) {
492
544
  try {
493
545
  const placeholders = chunkIds.map(() => "?").join(",");
494
- 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 }>;
495
- for (const r of sharedRows) sharingMap.set(r.source_chunk_id, r);
496
- const teamMetaRows = db.prepare(`SELECT chunk_id, visibility, group_id FROM team_shared_chunks WHERE chunk_id IN (${placeholders})`).all(...chunkIds) as Array<{ chunk_id: string; visibility: string; group_id: string | null }>;
497
- for (const r of teamMetaRows) {
498
- if (!sharingMap.has(r.chunk_id)) {
499
- sharingMap.set(r.chunk_id, { visibility: r.visibility, group_id: r.group_id });
500
- }
546
+ if (this.sharingRole === "hub") {
547
+ 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 }>;
548
+ for (const r of sharedRows) sharingMap.set(r.source_chunk_id, r);
549
+ } else {
550
+ const teamMetaRows = db.prepare(`SELECT chunk_id, visibility, group_id FROM team_shared_chunks WHERE chunk_id IN (${placeholders})`).all(...chunkIds) as Array<{ chunk_id: string; visibility: string; group_id: string | null }>;
551
+ for (const r of teamMetaRows) sharingMap.set(r.chunk_id, { visibility: r.visibility, group_id: r.group_id });
501
552
  }
502
553
  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 }>;
503
554
  for (const r of localRows) localShareMap.set(r.chunk_id, r);
@@ -564,7 +615,7 @@ export class ViewerServer {
564
615
  const db = (this.store as any).db;
565
616
  const items = tasks.map((t) => {
566
617
  const meta = db.prepare("SELECT skill_status, owner FROM tasks WHERE id = ?").get(t.id) as { skill_status: string | null; owner: string | null } | undefined;
567
- 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;
618
+ const hubTask = this.getHubTaskForLocal(t.id);
568
619
  return {
569
620
  id: t.id,
570
621
  sessionKey: t.sessionKey,
@@ -576,7 +627,7 @@ export class ViewerServer {
576
627
  chunkCount: this.store.countChunksByTask(t.id),
577
628
  skillStatus: meta?.skill_status ?? null,
578
629
  owner: meta?.owner ?? "agent:main",
579
- sharingVisibility: sharedTask?.visibility ?? null,
630
+ sharingVisibility: hubTask?.visibility ?? null,
580
631
  };
581
632
  });
582
633
 
@@ -611,7 +662,7 @@ export class ViewerServer {
611
662
  const db = (this.store as any).db;
612
663
  const meta = db.prepare("SELECT skill_status, skill_reason FROM tasks WHERE id = ?").get(taskId) as
613
664
  { skill_status: string | null; skill_reason: string | null } | undefined;
614
- 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;
665
+ const hubTask = this.getHubTaskForLocal(taskId);
615
666
 
616
667
  this.jsonResponse(res, {
617
668
  id: task.id,
@@ -626,9 +677,9 @@ export class ViewerServer {
626
677
  skillStatus: meta?.skill_status ?? null,
627
678
  skillReason: meta?.skill_reason ?? null,
628
679
  skillLinks,
629
- sharingVisibility: sharedTask?.visibility ?? null,
630
- sharingGroupId: sharedTask?.group_id ?? null,
631
- hubTaskId: sharedTask ? true : false,
680
+ sharingVisibility: hubTask?.visibility ?? null,
681
+ sharingGroupId: hubTask?.group_id ?? null,
682
+ hubTaskId: hubTask ? true : false,
632
683
  });
633
684
  }
634
685
 
@@ -818,10 +869,9 @@ export class ViewerServer {
818
869
  if (visibility) {
819
870
  skills = skills.filter(s => s.visibility === visibility);
820
871
  }
821
- const db = (this.store as any).db;
822
872
  const enriched = skills.map(s => {
823
- 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;
824
- return { ...s, sharingVisibility: hub?.visibility ?? null };
873
+ const hubSkill = this.getHubSkillForLocal(s.id);
874
+ return { ...s, sharingVisibility: hubSkill?.visibility ?? null };
825
875
  });
826
876
  this.jsonResponse(res, { skills: enriched });
827
877
  }
@@ -839,11 +889,10 @@ export class ViewerServer {
839
889
  const relatedTasks = this.store.getTasksBySkill(skillId);
840
890
  const files = fs.existsSync(skill.dirPath) ? this.walkDir(skill.dirPath, skill.dirPath) : [];
841
891
 
842
- const db = (this.store as any).db;
843
- 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;
892
+ const hubSkill = this.getHubSkillForLocal(skillId);
844
893
 
845
894
  this.jsonResponse(res, {
846
- skill: { ...skill, sharingVisibility: sharedSkill?.visibility ?? null, sharingGroupId: sharedSkill?.group_id ?? null },
895
+ skill: { ...skill, sharingVisibility: hubSkill?.visibility ?? null, sharingGroupId: hubSkill?.group_id ?? null },
847
896
  versions: versions.map(v => ({
848
897
  id: v.id,
849
898
  version: v.version,
@@ -982,7 +1031,7 @@ export class ViewerServer {
982
1031
  method: "POST",
983
1032
  body: JSON.stringify({ visibility: "public", groupId: null, metadata: bundle.metadata, bundle: bundle.bundle }),
984
1033
  }) as any;
985
- if (hubClient.userId) {
1034
+ if (this.sharingRole === "hub" && hubClient.userId) {
986
1035
  const existing = this.store.getHubSkillBySource(hubClient.userId, skillId);
987
1036
  this.store.upsertHubSkill({
988
1037
  id: response?.skillId ?? existing?.id ?? crypto.randomUUID(),
@@ -992,6 +1041,14 @@ export class ViewerServer {
992
1041
  bundle: JSON.stringify(bundle.bundle), qualityScore: skill.qualityScore,
993
1042
  createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
994
1043
  });
1044
+ } else {
1045
+ const conn = this.store.getClientHubConnection();
1046
+ this.store.upsertTeamSharedSkill(skillId, {
1047
+ hubSkillId: String(response?.skillId ?? ""),
1048
+ visibility: "public",
1049
+ groupId: null,
1050
+ hubInstanceId: conn?.hubInstanceId ?? "",
1051
+ });
995
1052
  }
996
1053
  hubSynced = true;
997
1054
  this.log.info(`Skill "${skill.name}" published to Hub`);
@@ -1000,7 +1057,8 @@ export class ViewerServer {
1000
1057
  method: "POST",
1001
1058
  body: JSON.stringify({ sourceSkillId: skillId }),
1002
1059
  });
1003
- if (hubClient.userId) this.store.deleteHubSkillBySource(hubClient.userId, skillId);
1060
+ if (this.sharingRole === "hub" && hubClient.userId) this.store.deleteHubSkillBySource(hubClient.userId, skillId);
1061
+ else this.store.deleteTeamSharedSkill(skillId);
1004
1062
  hubSynced = true;
1005
1063
  this.log.info(`Skill "${skill.name}" unpublished from Hub`);
1006
1064
  }
@@ -1271,7 +1329,8 @@ export class ViewerServer {
1271
1329
  createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1272
1330
  });
1273
1331
  } else if (hubClient.userId) {
1274
- this.store.upsertTeamSharedChunk(chunkId, { hubMemoryId: memoryId, visibility: "public", groupId: null });
1332
+ const conn = this.store.getClientHubConnection();
1333
+ this.store.upsertTeamSharedChunk(chunkId, { hubMemoryId: memoryId, visibility: "public", groupId: null, hubInstanceId: conn?.hubInstanceId ?? "" });
1275
1334
  }
1276
1335
  hubSynced = true;
1277
1336
  } else {
@@ -1284,7 +1343,7 @@ export class ViewerServer {
1284
1343
  await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/unshare", {
1285
1344
  method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
1286
1345
  });
1287
- if (hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1346
+ if (this.sharingRole === "hub" && hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1288
1347
  this.store.deleteTeamSharedChunk(chunkId);
1289
1348
  hubSynced = true;
1290
1349
  } catch (err) { this.log.warn(`Failed to unshare memory from team: ${err}`); }
@@ -1297,7 +1356,7 @@ export class ViewerServer {
1297
1356
  await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/unshare", {
1298
1357
  method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
1299
1358
  });
1300
- if (hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1359
+ if (this.sharingRole === "hub" && hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
1301
1360
  this.store.deleteTeamSharedChunk(chunkId);
1302
1361
  hubSynced = true;
1303
1362
  } catch (err) { this.log.warn(`Failed to unshare memory from team: ${err}`); }
@@ -1351,21 +1410,24 @@ export class ViewerServer {
1351
1410
  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() })),
1352
1411
  }),
1353
1412
  });
1354
- if (hubClient.userId) {
1413
+ const hubTaskId = String((response as any)?.taskId ?? "");
1414
+ if (this.sharingRole === "hub" && hubClient.userId) {
1355
1415
  const existing = this.store.getHubTaskBySource(hubClient.userId, taskId);
1356
1416
  this.store.upsertHubTask({
1357
- id: (response as any)?.taskId ?? existing?.id ?? crypto.randomUUID(),
1417
+ id: hubTaskId || existing?.id || crypto.randomUUID(),
1358
1418
  sourceTaskId: taskId, sourceUserId: hubClient.userId, title: refreshedTask.title ?? "",
1359
1419
  summary: refreshedTask.summary ?? "", groupId: null, visibility: "public",
1360
1420
  createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1361
1421
  });
1362
1422
  }
1423
+ const conn = this.store.getClientHubConnection();
1424
+ this.store.markTaskShared(taskId, hubTaskId, chunks.length, "public", null, conn?.hubInstanceId ?? "");
1363
1425
  hubSynced = true;
1364
1426
  }
1365
1427
  if (!isLocalShared) {
1366
1428
  const originalOwner = task.owner;
1367
1429
  const db = (this.store as any).db;
1368
- 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());
1430
+ db.prepare("INSERT INTO local_shared_tasks (task_id, hub_task_id, original_owner, hub_instance_id, shared_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(task_id) DO UPDATE SET original_owner = excluded.original_owner, hub_instance_id = excluded.hub_instance_id, shared_at = excluded.shared_at").run(taskId, "", originalOwner, "", Date.now());
1369
1431
  db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
1370
1432
  }
1371
1433
  }
@@ -1385,7 +1447,8 @@ export class ViewerServer {
1385
1447
  await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/unshare", {
1386
1448
  method: "POST", body: JSON.stringify({ sourceTaskId: taskId }),
1387
1449
  });
1388
- if (hubClient.userId) this.store.deleteHubTaskBySource(hubClient.userId, taskId);
1450
+ if (this.sharingRole === "hub" && hubClient.userId) this.store.deleteHubTaskBySource(hubClient.userId, taskId);
1451
+ else this.store.downgradeTeamSharedTaskToLocal(taskId);
1389
1452
  hubSynced = true;
1390
1453
  } catch (err) { this.log.warn(`Failed to unshare task from team: ${err}`); }
1391
1454
  }
@@ -1397,7 +1460,8 @@ export class ViewerServer {
1397
1460
  await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/unshare", {
1398
1461
  method: "POST", body: JSON.stringify({ sourceTaskId: taskId }),
1399
1462
  });
1400
- if (hubClient.userId) this.store.deleteHubTaskBySource(hubClient.userId, taskId);
1463
+ if (this.sharingRole === "hub" && hubClient.userId) this.store.deleteHubTaskBySource(hubClient.userId, taskId);
1464
+ else if (!isLocalShared) this.store.unmarkTaskShared(taskId);
1401
1465
  hubSynced = true;
1402
1466
  } catch (err) { this.log.warn(`Failed to unshare task from team: ${err}`); }
1403
1467
  }
@@ -1454,16 +1518,20 @@ export class ViewerServer {
1454
1518
  method: "POST",
1455
1519
  body: JSON.stringify({ visibility: "public", groupId: null, metadata: bundle.metadata, bundle: bundle.bundle }),
1456
1520
  });
1457
- if (hubClient.userId) {
1521
+ const hubSkillId = String((response as any)?.skillId ?? "");
1522
+ if (this.sharingRole === "hub" && hubClient.userId) {
1458
1523
  const existing = this.store.getHubSkillBySource(hubClient.userId, skillId);
1459
1524
  this.store.upsertHubSkill({
1460
- id: (response as any)?.skillId ?? existing?.id ?? crypto.randomUUID(),
1525
+ id: hubSkillId || existing?.id || crypto.randomUUID(),
1461
1526
  sourceSkillId: skillId, sourceUserId: hubClient.userId,
1462
1527
  name: skill.name, description: skill.description, version: skill.version,
1463
1528
  groupId: null, visibility: "public",
1464
1529
  bundle: JSON.stringify(bundle.bundle), qualityScore: skill.qualityScore,
1465
1530
  createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
1466
1531
  });
1532
+ } else {
1533
+ const conn = this.store.getClientHubConnection();
1534
+ this.store.upsertTeamSharedSkill(skillId, { hubSkillId, visibility: "public", groupId: null, hubInstanceId: conn?.hubInstanceId ?? "" });
1467
1535
  }
1468
1536
  hubSynced = true;
1469
1537
  }
@@ -1480,7 +1548,8 @@ export class ViewerServer {
1480
1548
  await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/unpublish", {
1481
1549
  method: "POST", body: JSON.stringify({ sourceSkillId: skillId }),
1482
1550
  });
1483
- if (hubClient.userId) this.store.deleteHubSkillBySource(hubClient.userId, skillId);
1551
+ if (this.sharingRole === "hub" && hubClient.userId) this.store.deleteHubSkillBySource(hubClient.userId, skillId);
1552
+ else this.store.deleteTeamSharedSkill(skillId);
1484
1553
  hubSynced = true;
1485
1554
  } catch (err) { this.log.warn(`Failed to unpublish skill from team: ${err}`); }
1486
1555
  }
@@ -1492,7 +1561,8 @@ export class ViewerServer {
1492
1561
  await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/unpublish", {
1493
1562
  method: "POST", body: JSON.stringify({ sourceSkillId: skillId }),
1494
1563
  });
1495
- if (hubClient.userId) this.store.deleteHubSkillBySource(hubClient.userId, skillId);
1564
+ if (this.sharingRole === "hub" && hubClient.userId) this.store.deleteHubSkillBySource(hubClient.userId, skillId);
1565
+ else this.store.deleteTeamSharedSkill(skillId);
1496
1566
  hubSynced = true;
1497
1567
  } catch (err) { this.log.warn(`Failed to unpublish skill from team: ${err}`); }
1498
1568
  }
@@ -1506,29 +1576,53 @@ export class ViewerServer {
1506
1576
  });
1507
1577
  }
1508
1578
 
1579
+ private get sharingRole(): string | undefined {
1580
+ return this.ctx?.config?.sharing?.role;
1581
+ }
1582
+
1583
+ private isCurrentClientHubInstance(hubInstanceId?: string): boolean {
1584
+ if (this.sharingRole !== "client") return true;
1585
+ const scopedHubInstanceId = String(hubInstanceId ?? "");
1586
+ if (!scopedHubInstanceId) return true;
1587
+ const currentHubInstanceId = this.store.getClientHubConnection()?.hubInstanceId ?? "";
1588
+ if (!currentHubInstanceId) return true;
1589
+ return scopedHubInstanceId === currentHubInstanceId;
1590
+ }
1591
+
1509
1592
  private getHubMemoryForChunk(chunkId: string): any {
1510
- const db = (this.store as any).db;
1511
- const hub = db.prepare("SELECT * FROM hub_memories WHERE source_chunk_id = ? LIMIT 1").get(chunkId);
1512
- if (hub) return hub;
1593
+ if (this.sharingRole === "hub") {
1594
+ const db = (this.store as any).db;
1595
+ return db.prepare("SELECT * FROM hub_memories WHERE source_chunk_id = ? LIMIT 1").get(chunkId);
1596
+ }
1513
1597
  const ts = this.store.getTeamSharedChunk(chunkId);
1514
- if (ts) {
1515
- return {
1516
- source_chunk_id: chunkId,
1517
- visibility: ts.visibility,
1518
- group_id: ts.groupId,
1519
- };
1598
+ if (ts && this.isCurrentClientHubInstance(ts.hubInstanceId)) {
1599
+ return { source_chunk_id: chunkId, visibility: ts.visibility, group_id: ts.groupId };
1520
1600
  }
1521
1601
  return undefined;
1522
1602
  }
1523
1603
 
1524
1604
  private getHubTaskForLocal(taskId: string): any {
1525
- const db = (this.store as any).db;
1526
- return db.prepare("SELECT * FROM hub_tasks WHERE source_task_id = ? LIMIT 1").get(taskId);
1605
+ if (this.sharingRole === "hub") {
1606
+ const db = (this.store as any).db;
1607
+ return db.prepare("SELECT * FROM hub_tasks WHERE source_task_id = ? LIMIT 1").get(taskId);
1608
+ }
1609
+ const shared = this.store.getLocalSharedTask(taskId);
1610
+ if (shared && shared.hubTaskId && this.isCurrentClientHubInstance(shared.hubInstanceId)) {
1611
+ return { source_task_id: taskId, visibility: shared.visibility, group_id: shared.groupId };
1612
+ }
1613
+ return undefined;
1527
1614
  }
1528
1615
 
1529
1616
  private getHubSkillForLocal(skillId: string): any {
1530
- const db = (this.store as any).db;
1531
- return db.prepare("SELECT * FROM hub_skills WHERE source_skill_id = ? LIMIT 1").get(skillId);
1617
+ if (this.sharingRole === "hub") {
1618
+ const db = (this.store as any).db;
1619
+ return db.prepare("SELECT * FROM hub_skills WHERE source_skill_id = ? LIMIT 1").get(skillId);
1620
+ }
1621
+ const ts = this.store.getTeamSharedSkill(skillId);
1622
+ if (ts && this.isCurrentClientHubInstance(ts.hubInstanceId)) {
1623
+ return { source_skill_id: skillId, visibility: ts.visibility, group_id: ts.groupId };
1624
+ }
1625
+ return undefined;
1532
1626
  }
1533
1627
 
1534
1628
  private handleDeleteSession(res: http.ServerResponse, url: URL): void {
@@ -1821,18 +1915,25 @@ export class ViewerServer {
1821
1915
 
1822
1916
  private handleRetryJoin(req: http.IncomingMessage, res: http.ServerResponse): void {
1823
1917
  this.readBody(req, async (_body) => {
1824
- if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
1918
+ if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable", errorCode: "sharing_unavailable" });
1825
1919
  const sharing = this.ctx.config.sharing;
1826
1920
  if (!sharing?.enabled || sharing.role !== "client") {
1827
- return this.jsonResponse(res, { ok: false, error: "not_in_client_mode" });
1921
+ return this.jsonResponse(res, { ok: false, error: "not_in_client_mode", errorCode: "not_in_client_mode" });
1828
1922
  }
1829
1923
  const hubAddress = sharing.client?.hubAddress ?? "";
1830
1924
  const teamToken = sharing.client?.teamToken ?? "";
1831
1925
  if (!hubAddress || !teamToken) {
1832
- return this.jsonResponse(res, { ok: false, error: "missing_hub_address_or_team_token" });
1926
+ return this.jsonResponse(res, { ok: false, error: "missing_hub_address_or_team_token", errorCode: "missing_config" });
1927
+ }
1928
+ const hubUrl = normalizeHubUrl(hubAddress);
1929
+
1930
+ try {
1931
+ await hubRequestJson(hubUrl, "", "/api/v1/hub/info", { method: "GET" });
1932
+ } catch {
1933
+ return this.jsonResponse(res, { ok: false, error: "hub_unreachable", errorCode: "hub_unreachable" });
1833
1934
  }
1935
+
1834
1936
  try {
1835
- const hubUrl = normalizeHubUrl(hubAddress);
1836
1937
  const os = await import("os");
1837
1938
  const nickname = sharing.client?.nickname;
1838
1939
  const username = nickname || os.userInfo().username || "user";
@@ -1844,6 +1945,11 @@ export class ViewerServer {
1844
1945
  body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true, identityKey: existingIdentityKey }),
1845
1946
  }) as any;
1846
1947
  const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
1948
+ let hubInstanceId = persisted?.hubInstanceId || "";
1949
+ try {
1950
+ const info = await hubRequestJson(hubUrl, "", "/api/v1/hub/info", { method: "GET" }) as any;
1951
+ hubInstanceId = String(info?.hubInstanceId ?? hubInstanceId);
1952
+ } catch { /* best-effort */ }
1847
1953
  this.store.setClientHubConnection({
1848
1954
  hubUrl,
1849
1955
  userId: String(result.userId || ""),
@@ -1853,10 +1959,21 @@ export class ViewerServer {
1853
1959
  connectedAt: Date.now(),
1854
1960
  identityKey: returnedIdentityKey,
1855
1961
  lastKnownStatus: result.status || "",
1962
+ hubInstanceId,
1856
1963
  });
1964
+ if (result.status === "blocked") {
1965
+ return this.jsonResponse(res, { ok: false, error: "blocked", errorCode: "blocked" });
1966
+ }
1857
1967
  this.jsonResponse(res, { ok: true, status: result.status || "pending" });
1858
1968
  } catch (err) {
1859
- this.jsonResponse(res, { ok: false, error: String(err) });
1969
+ const errStr = String(err);
1970
+ if (errStr.includes("(409)") || errStr.includes("username_taken")) {
1971
+ return this.jsonResponse(res, { ok: false, error: "username_taken", errorCode: "username_taken" });
1972
+ }
1973
+ if (errStr.includes("(403)") || errStr.includes("invalid_team_token")) {
1974
+ return this.jsonResponse(res, { ok: false, error: "invalid_team_token", errorCode: "invalid_team_token" });
1975
+ }
1976
+ this.jsonResponse(res, { ok: false, error: errStr, errorCode: "unknown" });
1860
1977
  }
1861
1978
  });
1862
1979
  }
@@ -2060,9 +2177,10 @@ export class ViewerServer {
2060
2177
  }),
2061
2178
  });
2062
2179
  const hubUserId = hubClient.userId;
2063
- if (hubUserId) {
2180
+ const hubTaskId = String((response as any)?.taskId ?? task.id);
2181
+ if (this.sharingRole === "hub" && hubUserId) {
2064
2182
  this.store.upsertHubTask({
2065
- id: task.id,
2183
+ id: hubTaskId,
2066
2184
  sourceTaskId: task.id,
2067
2185
  sourceUserId: hubUserId,
2068
2186
  title: task.title,
@@ -2072,6 +2190,9 @@ export class ViewerServer {
2072
2190
  createdAt: task.startedAt ?? Date.now(),
2073
2191
  updatedAt: task.updatedAt ?? Date.now(),
2074
2192
  });
2193
+ } else {
2194
+ const conn = this.store.getClientHubConnection();
2195
+ this.store.markTaskShared(task.id, hubTaskId, chunks.length, visibility, groupId, conn?.hubInstanceId ?? "");
2075
2196
  }
2076
2197
  this.jsonResponse(res, { ok: true, taskId, visibility, response });
2077
2198
  } catch (err) {
@@ -2094,7 +2215,9 @@ export class ViewerServer {
2094
2215
  body: JSON.stringify({ sourceTaskId: task.id }),
2095
2216
  });
2096
2217
  const hubUserId = hubClient.userId;
2097
- if (hubUserId) this.store.deleteHubTaskBySource(hubUserId, task.id);
2218
+ if (this.sharingRole === "hub" && hubUserId) this.store.deleteHubTaskBySource(hubUserId, task.id);
2219
+ else if (task.owner === "public") this.store.downgradeTeamSharedTaskToLocal(task.id);
2220
+ else this.store.unmarkTaskShared(task.id);
2098
2221
  this.jsonResponse(res, { ok: true, taskId });
2099
2222
  } catch (err) {
2100
2223
  this.jsonResponse(res, { ok: false, error: String(err) });
@@ -2146,7 +2269,8 @@ export class ViewerServer {
2146
2269
  updatedAt: now,
2147
2270
  });
2148
2271
  } else if (hubClient.userId) {
2149
- this.store.upsertTeamSharedChunk(chunk.id, { hubMemoryId: mid, visibility, groupId });
2272
+ const conn = this.store.getClientHubConnection();
2273
+ this.store.upsertTeamSharedChunk(chunk.id, { hubMemoryId: mid, visibility, groupId, hubInstanceId: conn?.hubInstanceId ?? "" });
2150
2274
  }
2151
2275
  this.jsonResponse(res, { ok: true, chunkId, visibility, response });
2152
2276
  } catch (err) {
@@ -2167,8 +2291,8 @@ export class ViewerServer {
2167
2291
  body: JSON.stringify({ sourceChunkId: chunkId }),
2168
2292
  });
2169
2293
  const hubUserId = hubClient.userId;
2170
- if (hubUserId) this.store.deleteHubMemoryBySource(hubUserId, chunkId);
2171
- this.store.deleteTeamSharedChunk(chunkId);
2294
+ if (this.sharingRole === "hub" && hubUserId) this.store.deleteHubMemoryBySource(hubUserId, chunkId);
2295
+ else this.store.deleteTeamSharedChunk(chunkId);
2172
2296
  this.jsonResponse(res, { ok: true, chunkId });
2173
2297
  } catch (err) {
2174
2298
  this.jsonResponse(res, { ok: false, error: String(err) });
@@ -2213,7 +2337,7 @@ export class ViewerServer {
2213
2337
  }),
2214
2338
  });
2215
2339
  const hubUserId = hubClient.userId;
2216
- if (hubUserId) {
2340
+ if (this.sharingRole === "hub" && hubUserId) {
2217
2341
  const existing = this.store.getHubSkillBySource(hubUserId, skillId);
2218
2342
  this.store.upsertHubSkill({
2219
2343
  id: (response as any)?.skillId ?? existing?.id ?? crypto.randomUUID(),
@@ -2229,6 +2353,14 @@ export class ViewerServer {
2229
2353
  createdAt: existing?.createdAt ?? Date.now(),
2230
2354
  updatedAt: Date.now(),
2231
2355
  });
2356
+ } else {
2357
+ const conn = this.store.getClientHubConnection();
2358
+ this.store.upsertTeamSharedSkill(skillId, {
2359
+ hubSkillId: String((response as any)?.skillId ?? ""),
2360
+ visibility,
2361
+ groupId,
2362
+ hubInstanceId: conn?.hubInstanceId ?? "",
2363
+ });
2232
2364
  }
2233
2365
  this.jsonResponse(res, { ok: true, skillId, visibility, response });
2234
2366
  } catch (err) {
@@ -2251,7 +2383,8 @@ export class ViewerServer {
2251
2383
  body: JSON.stringify({ sourceSkillId: skill.id }),
2252
2384
  });
2253
2385
  const hubUserId = hubClient.userId;
2254
- if (hubUserId) this.store.deleteHubSkillBySource(hubUserId, skill.id);
2386
+ if (this.sharingRole === "hub" && hubUserId) this.store.deleteHubSkillBySource(hubUserId, skill.id);
2387
+ else this.store.deleteTeamSharedSkill(skill.id);
2255
2388
  this.jsonResponse(res, { ok: true, skillId });
2256
2389
  } catch (err) {
2257
2390
  this.jsonResponse(res, { ok: false, error: String(err) });
@@ -2717,19 +2850,21 @@ export class ViewerServer {
2717
2850
  const isClient = newEnabled && newRole === "client";
2718
2851
  if (wasClient && !isClient) {
2719
2852
  await this.withdrawOrLeaveHub();
2853
+ this.store.clearAllTeamSharingState();
2720
2854
  this.store.clearClientHubConnection();
2721
- this.log.info("Client hub connection cleared (sharing disabled or role changed)");
2855
+ this.log.info("Client hub connection and team sharing state cleared (sharing disabled or role changed)");
2722
2856
  }
2723
2857
 
2724
2858
  if (wasClient && isClient) {
2725
2859
  const newClientAddr = String((merged.client as Record<string, unknown>)?.hubAddress || "");
2726
2860
  if (newClientAddr && oldClientHubAddress && normalizeHubUrl(newClientAddr) !== normalizeHubUrl(oldClientHubAddress)) {
2727
2861
  this.notifyHubLeave();
2862
+ this.store.clearAllTeamSharingState();
2728
2863
  const oldConn = this.store.getClientHubConnection();
2729
2864
  if (oldConn) {
2730
- this.store.setClientHubConnection({ ...oldConn, hubUrl: normalizeHubUrl(newClientAddr), userToken: "", lastKnownStatus: "hub_changed" });
2865
+ this.store.setClientHubConnection({ ...oldConn, hubUrl: normalizeHubUrl(newClientAddr), userToken: "", hubInstanceId: "", lastKnownStatus: "hub_changed" });
2731
2866
  }
2732
- this.log.info("Client hub connection token cleared (switched to different Hub), identity preserved");
2867
+ this.log.info("Client hub connection and team sharing state cleared (switched to different Hub)");
2733
2868
  }
2734
2869
  }
2735
2870
 
@@ -2751,20 +2886,25 @@ export class ViewerServer {
2751
2886
  const nowClient = Boolean(finalSharing?.enabled) && finalSharing?.role === "client";
2752
2887
  const previouslyClient = oldSharingEnabled && oldSharingRole === "client";
2753
2888
  let joinStatus: string | undefined;
2889
+ let joinError: string | undefined;
2754
2890
  if (nowClient && !previouslyClient) {
2755
2891
  try {
2756
2892
  joinStatus = await this.autoJoinOnSave(finalSharing);
2757
2893
  } catch (e) {
2758
- this.log.warn(`Auto-join on save failed: ${e}`);
2894
+ const msg = String(e instanceof Error ? e.message : e);
2895
+ this.log.warn(`Auto-join on save failed: ${msg}`);
2896
+ if (msg === "hub_unreachable" || msg === "username_taken" || msg === "invalid_team_token") {
2897
+ joinError = msg;
2898
+ }
2759
2899
  }
2760
2900
  }
2761
2901
 
2762
- this.jsonResponse(res, { ok: true, joinStatus, restart: true });
2902
+ if (joinError) {
2903
+ this.jsonResponse(res, { ok: true, joinError, restart: false });
2904
+ return;
2905
+ }
2763
2906
 
2764
- setTimeout(() => {
2765
- this.log.info("config-save: triggering gateway restart via SIGUSR1...");
2766
- try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
2767
- }, 500);
2907
+ this.jsonResponseAndRestart(res, { ok: true, joinStatus, restart: true }, "config-save");
2768
2908
  } catch (e) {
2769
2909
  this.log.warn(`handleSaveConfig error: ${e}`);
2770
2910
  res.writeHead(500, { "Content-Type": "application/json" });
@@ -2779,17 +2919,43 @@ export class ViewerServer {
2779
2919
  const teamToken = String(clientCfg?.teamToken || "");
2780
2920
  if (!hubAddress || !teamToken) return undefined;
2781
2921
  const hubUrl = normalizeHubUrl(hubAddress);
2922
+
2923
+ try {
2924
+ await hubRequestJson(hubUrl, "", "/api/v1/hub/info", { method: "GET" });
2925
+ } catch {
2926
+ throw new Error("hub_unreachable");
2927
+ }
2928
+
2782
2929
  const os = await import("os");
2783
2930
  const nickname = String(clientCfg?.nickname || "");
2784
2931
  const username = nickname || os.userInfo().username || "user";
2785
2932
  const hostname = os.hostname() || "unknown";
2786
2933
  const persisted = this.store.getClientHubConnection();
2787
2934
  const existingIdentityKey = persisted?.identityKey || "";
2788
- const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
2789
- method: "POST",
2790
- body: JSON.stringify({ teamToken, username, deviceName: hostname, identityKey: existingIdentityKey }),
2791
- }) as any;
2935
+
2936
+ let result: any;
2937
+ try {
2938
+ result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
2939
+ method: "POST",
2940
+ body: JSON.stringify({ teamToken, username, deviceName: hostname, identityKey: existingIdentityKey }),
2941
+ });
2942
+ } catch (err) {
2943
+ const errStr = String(err);
2944
+ if (errStr.includes("(409)") || errStr.includes("username_taken")) {
2945
+ throw new Error("username_taken");
2946
+ }
2947
+ if (errStr.includes("(403)") || errStr.includes("invalid_team_token")) {
2948
+ throw new Error("invalid_team_token");
2949
+ }
2950
+ throw err;
2951
+ }
2952
+
2792
2953
  const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
2954
+ let hubInstanceId = persisted?.hubInstanceId || "";
2955
+ try {
2956
+ const info = await hubRequestJson(hubUrl, "", "/api/v1/hub/info", { method: "GET" }) as any;
2957
+ hubInstanceId = String(info?.hubInstanceId ?? hubInstanceId);
2958
+ } catch { /* best-effort */ }
2793
2959
  this.store.setClientHubConnection({
2794
2960
  hubUrl,
2795
2961
  userId: String(result.userId || ""),
@@ -2799,6 +2965,7 @@ export class ViewerServer {
2799
2965
  connectedAt: Date.now(),
2800
2966
  identityKey: returnedIdentityKey,
2801
2967
  lastKnownStatus: result.status || "",
2968
+ hubInstanceId,
2802
2969
  });
2803
2970
  this.log.info(`Auto-join on save: status=${result.status}, userId=${result.userId}`);
2804
2971
  if (result.userToken) {
@@ -2811,6 +2978,7 @@ export class ViewerServer {
2811
2978
  this.readBody(_req, async () => {
2812
2979
  try {
2813
2980
  await this.withdrawOrLeaveHub();
2981
+ this.store.clearAllTeamSharingState();
2814
2982
  this.store.clearClientHubConnection();
2815
2983
 
2816
2984
  const configPath = this.getOpenClawConfigPath();
@@ -2829,12 +2997,7 @@ export class ViewerServer {
2829
2997
  }
2830
2998
  }
2831
2999
 
2832
- this.jsonResponse(res, { ok: true, restart: true });
2833
-
2834
- setTimeout(() => {
2835
- this.log.info("handleLeaveTeam: triggering gateway restart via SIGUSR1...");
2836
- try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
2837
- }, 500);
3000
+ this.jsonResponseAndRestart(res, { ok: true, restart: true }, "handleLeaveTeam");
2838
3001
  } catch (e) {
2839
3002
  this.log.warn(`handleLeaveTeam error: ${e}`);
2840
3003
  this.jsonResponse(res, { ok: false, error: String(e) });
@@ -3000,14 +3163,43 @@ export class ViewerServer {
3000
3163
  }
3001
3164
  }
3002
3165
  } catch {}
3003
- const url = hubUrl.replace(/\/+$/, "") + "/api/v1/hub/info";
3166
+ const baseUrl = hubUrl.replace(/\/+$/, "");
3167
+ const infoUrl = baseUrl + "/api/v1/hub/info";
3004
3168
  const ctrl = new AbortController();
3005
3169
  const timeout = setTimeout(() => ctrl.abort(), 8000);
3006
3170
  try {
3007
- const r = await fetch(url, { signal: ctrl.signal });
3171
+ const r = await fetch(infoUrl, { signal: ctrl.signal });
3008
3172
  clearTimeout(timeout);
3009
3173
  if (!r.ok) { this.jsonResponse(res, { ok: false, error: `HTTP ${r.status}` }); return; }
3010
3174
  const info = await r.json() as Record<string, unknown>;
3175
+
3176
+ const { teamToken, nickname } = JSON.parse(body);
3177
+ if (teamToken) {
3178
+ const username = (typeof nickname === "string" && nickname.trim()) || os.userInfo().username || "user";
3179
+ const persisted = this.store.getClientHubConnection();
3180
+ const identityKey = persisted?.identityKey || "";
3181
+ try {
3182
+ const joinR = await fetch(baseUrl + "/api/v1/hub/join", {
3183
+ method: "POST",
3184
+ headers: { "content-type": "application/json" },
3185
+ body: JSON.stringify({ teamToken, username, identityKey, deviceName: os.hostname(), dryRun: true }),
3186
+ });
3187
+ const joinData = await joinR.json() as Record<string, unknown>;
3188
+ if (!joinR.ok && joinData.error === "username_taken") {
3189
+ this.jsonResponse(res, { ok: false, error: "username_taken", teamName: info.teamName || "" });
3190
+ return;
3191
+ }
3192
+ if (!joinR.ok && joinData.error === "invalid_team_token") {
3193
+ this.jsonResponse(res, { ok: false, error: "invalid_team_token", teamName: info.teamName || "" });
3194
+ return;
3195
+ }
3196
+ if (joinR.ok && joinData.status === "blocked") {
3197
+ this.jsonResponse(res, { ok: false, error: "blocked", teamName: info.teamName || "" });
3198
+ return;
3199
+ }
3200
+ } catch { /* join check is best-effort; connection itself is OK */ }
3201
+ }
3202
+
3011
3203
  this.jsonResponse(res, { ok: true, teamName: info.teamName || "", apiVersion: info.apiVersion || "" });
3012
3204
  } catch (e: unknown) {
3013
3205
  clearTimeout(timeout);
@@ -3092,26 +3284,35 @@ export class ViewerServer {
3092
3284
  }
3093
3285
 
3094
3286
  private async handleUpdateCheck(res: http.ServerResponse): Promise<void> {
3287
+ const sendNoStore = (data: unknown, statusCode = 200) => {
3288
+ res.writeHead(statusCode, {
3289
+ "Content-Type": "application/json; charset=utf-8",
3290
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
3291
+ "Pragma": "no-cache",
3292
+ "Expires": "0",
3293
+ });
3294
+ res.end(JSON.stringify(data));
3295
+ };
3095
3296
  try {
3096
3297
  const pkgPath = this.findPluginPackageJson();
3097
3298
  if (!pkgPath) {
3098
- this.jsonResponse(res, { updateAvailable: false, error: "package.json not found" });
3299
+ sendNoStore({ updateAvailable: false, error: "package.json not found" });
3099
3300
  return;
3100
3301
  }
3101
3302
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
3102
3303
  const current = pkg.version as string;
3103
3304
  const name = pkg.name as string;
3104
3305
  if (!current || !name) {
3105
- this.jsonResponse(res, { updateAvailable: false, current });
3306
+ sendNoStore({ updateAvailable: false, current });
3106
3307
  return;
3107
3308
  }
3108
3309
  const { computeUpdateCheck } = await import("../update-check");
3109
3310
  const result = await computeUpdateCheck(name, current, fetch, 6_000);
3110
3311
  if (!result) {
3111
- this.jsonResponse(res, { updateAvailable: false, current, packageName: name });
3312
+ sendNoStore({ updateAvailable: false, current, packageName: name });
3112
3313
  return;
3113
3314
  }
3114
- this.jsonResponse(res, {
3315
+ sendNoStore({
3115
3316
  updateAvailable: result.updateAvailable,
3116
3317
  current: result.current,
3117
3318
  latest: result.latest,
@@ -3122,7 +3323,7 @@ export class ViewerServer {
3122
3323
  });
3123
3324
  } catch (e) {
3124
3325
  this.log.warn(`handleUpdateCheck error: ${e}`);
3125
- this.jsonResponse(res, { updateAvailable: false, error: String(e) });
3326
+ sendNoStore({ updateAvailable: false, error: String(e) });
3126
3327
  }
3127
3328
  }
3128
3329
 
@@ -3131,13 +3332,14 @@ export class ViewerServer {
3131
3332
  req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
3132
3333
  req.on("end", () => {
3133
3334
  try {
3134
- const { packageSpec: rawSpec } = JSON.parse(body);
3335
+ const { packageSpec: rawSpec, targetVersion: rawTargetVersion } = JSON.parse(body);
3135
3336
  if (!rawSpec || typeof rawSpec !== "string") {
3136
3337
  res.writeHead(400, { "Content-Type": "application/json" });
3137
3338
  res.end(JSON.stringify({ ok: false, error: "Missing packageSpec" }));
3138
3339
  return;
3139
3340
  }
3140
3341
  const packageSpec = rawSpec.trim().replace(/^(?:npx\s+)?openclaw\s+plugins\s+install\s+/i, "");
3342
+ const targetVersion = typeof rawTargetVersion === "string" ? rawTargetVersion.trim() : "";
3141
3343
  const allowed = /^@[\w-]+\/[\w.-]+(@[\w.-]+)?$/;
3142
3344
  this.log.info(`update-install: received packageSpec="${packageSpec}" (len=${packageSpec.length})`);
3143
3345
  if (!allowed.test(packageSpec)) {
@@ -3154,16 +3356,42 @@ export class ViewerServer {
3154
3356
  const shortName = pluginName?.replace(/^@[\w-]+\//, "") ?? "memos-local-openclaw-plugin";
3155
3357
  const extDir = path.join(os.homedir(), ".openclaw", "extensions", shortName);
3156
3358
  const tmpDir = path.join(os.tmpdir(), `openclaw-update-${Date.now()}`);
3359
+ const backupDir = path.join(path.dirname(extDir), `${shortName}.backup-${Date.now()}`);
3360
+ let backupReady = false;
3361
+
3362
+ const cleanupTmpDir = () => {
3363
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
3364
+ };
3365
+ const rollbackInstall = () => {
3366
+ try { fs.rmSync(extDir, { recursive: true, force: true }); } catch {}
3367
+ if (!backupReady) return;
3368
+ try {
3369
+ fs.renameSync(backupDir, extDir);
3370
+ backupReady = false;
3371
+ this.log.info(`update-install: restored previous version from ${backupDir}`);
3372
+ } catch (restoreErr: any) {
3373
+ this.log.warn(`update-install: failed to restore previous version: ${restoreErr?.message ?? restoreErr}`);
3374
+ }
3375
+ };
3376
+ const discardBackup = () => {
3377
+ if (!backupReady) return;
3378
+ try {
3379
+ fs.rmSync(backupDir, { recursive: true, force: true });
3380
+ backupReady = false;
3381
+ } catch (cleanupErr: any) {
3382
+ this.log.warn(`update-install: failed to remove backup dir ${backupDir}: ${cleanupErr?.message ?? cleanupErr}`);
3383
+ }
3384
+ };
3157
3385
 
3158
3386
  // Download via npm pack, extract, and replace extension dir.
3159
3387
  // Does NOT touch openclaw.json → no config watcher SIGUSR1.
3160
3388
  this.log.info(`update-install: downloading ${packageSpec} via npm pack...`);
3161
3389
  fs.mkdirSync(tmpDir, { recursive: true });
3162
- exec(`npm pack ${packageSpec} --pack-destination ${tmpDir}`, { timeout: 60_000 }, (packErr, packOut) => {
3390
+ exec(`npm pack ${packageSpec} --pack-destination ${tmpDir} --prefer-online`, { timeout: 60_000 }, (packErr, packOut) => {
3163
3391
  if (packErr) {
3164
3392
  this.log.warn(`update-install: npm pack failed: ${packErr.message}`);
3165
3393
  this.jsonResponse(res, { ok: false, error: `Download failed: ${packErr.message}` });
3166
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
3394
+ cleanupTmpDir();
3167
3395
  return;
3168
3396
  }
3169
3397
  const tgzFile = packOut.trim().split("\n").pop()!;
@@ -3176,7 +3404,7 @@ export class ViewerServer {
3176
3404
  if (tarErr) {
3177
3405
  this.log.warn(`update-install: tar extract failed: ${tarErr.message}`);
3178
3406
  this.jsonResponse(res, { ok: false, error: `Extract failed: ${tarErr.message}` });
3179
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
3407
+ cleanupTmpDir();
3180
3408
  return;
3181
3409
  }
3182
3410
 
@@ -3184,61 +3412,79 @@ export class ViewerServer {
3184
3412
  const srcDir = path.join(extractDir, "package");
3185
3413
  if (!fs.existsSync(srcDir)) {
3186
3414
  this.jsonResponse(res, { ok: false, error: "Extracted package has no 'package' dir" });
3187
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
3415
+ cleanupTmpDir();
3188
3416
  return;
3189
3417
  }
3190
3418
 
3191
3419
  // Replace extension directory
3192
3420
  this.log.info(`update-install: replacing ${extDir}...`);
3193
- try { fs.rmSync(extDir, { recursive: true, force: true }); } catch {}
3194
- fs.mkdirSync(path.dirname(extDir), { recursive: true });
3195
- fs.renameSync(srcDir, extDir);
3421
+ try {
3422
+ fs.mkdirSync(path.dirname(extDir), { recursive: true });
3423
+ try { fs.rmSync(backupDir, { recursive: true, force: true }); } catch {}
3424
+ if (fs.existsSync(extDir)) {
3425
+ fs.renameSync(extDir, backupDir);
3426
+ backupReady = true;
3427
+ }
3428
+ fs.renameSync(srcDir, extDir);
3429
+ } catch (replaceErr: any) {
3430
+ this.log.warn(`update-install: replace failed: ${replaceErr?.message ?? replaceErr}`);
3431
+ cleanupTmpDir();
3432
+ rollbackInstall();
3433
+ this.jsonResponse(res, { ok: false, error: `Replace failed: ${replaceErr?.message ?? replaceErr}` });
3434
+ return;
3435
+ }
3196
3436
 
3197
3437
  // Install dependencies
3198
3438
  this.log.info(`update-install: installing dependencies...`);
3199
- exec(`cd ${extDir} && npm install --omit=dev --ignore-scripts`, { timeout: 120_000 }, (npmErr, npmOut, npmStderr) => {
3439
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
3440
+ execFile(npmCmd, ["install", "--omit=dev", "--ignore-scripts"], { cwd: extDir, timeout: 120_000 }, (npmErr, npmOut, npmStderr) => {
3200
3441
  if (npmErr) {
3201
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
3202
3442
  this.log.warn(`update-install: npm install failed: ${npmErr.message}`);
3443
+ cleanupTmpDir();
3444
+ rollbackInstall();
3203
3445
  this.jsonResponse(res, { ok: false, error: `Dependency install failed: ${npmStderr || npmErr.message}` });
3204
3446
  return;
3205
3447
  }
3206
3448
 
3207
- // Rebuild native modules (do not swallow errors)
3208
- exec(`cd ${extDir} && npm rebuild better-sqlite3`, { timeout: 60_000 }, (rebuildErr, rebuildOut, rebuildStderr) => {
3449
+ execFile(npmCmd, ["rebuild", "better-sqlite3"], { cwd: extDir, timeout: 60_000 }, (rebuildErr, rebuildOut, rebuildStderr) => {
3209
3450
  if (rebuildErr) {
3210
3451
  this.log.warn(`update-install: better-sqlite3 rebuild failed: ${rebuildErr.message}`);
3211
3452
  const stderr = String(rebuildStderr || "").trim();
3212
3453
  if (stderr) this.log.warn(`update-install: rebuild stderr: ${stderr.slice(0, 500)}`);
3213
- // Continue so postinstall.cjs can run (it will try rebuild again and show user guidance)
3214
3454
  }
3215
3455
 
3216
- // Run postinstall.cjs: legacy cleanup, skill install, version marker, and optional sqlite re-check
3217
3456
  this.log.info(`update-install: running postinstall...`);
3218
- exec(`cd ${extDir} && node scripts/postinstall.cjs`, { timeout: 180_000 }, (postErr, postOut, postStderr) => {
3219
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
3457
+ execFile(process.execPath, ["scripts/postinstall.cjs"], { cwd: extDir, timeout: 180_000 }, (postErr, postOut, postStderr) => {
3458
+ cleanupTmpDir();
3220
3459
 
3221
3460
  if (postErr) {
3222
3461
  this.log.warn(`update-install: postinstall failed: ${postErr.message}`);
3223
3462
  const postStderrStr = String(postStderr || "").trim();
3224
3463
  if (postStderrStr) this.log.warn(`update-install: postinstall stderr: ${postStderrStr.slice(0, 500)}`);
3225
- // Still report success; plugin is updated, user can run postinstall manually if needed
3464
+ rollbackInstall();
3465
+ this.jsonResponse(res, { ok: false, error: `Postinstall failed: ${postStderrStr || postErr.message}` });
3466
+ return;
3226
3467
  }
3227
3468
 
3228
- // Read new version
3229
3469
  let newVersion = "unknown";
3230
3470
  try {
3231
3471
  const newPkg = JSON.parse(fs.readFileSync(path.join(extDir, "package.json"), "utf-8"));
3232
3472
  newVersion = newPkg.version ?? newVersion;
3233
3473
  } catch {}
3234
3474
 
3235
- this.log.info(`update-install: success! Updated to ${newVersion}`);
3236
- this.jsonResponse(res, { ok: true, version: newVersion });
3475
+ if (targetVersion && newVersion !== targetVersion) {
3476
+ this.log.warn(`update-install: version mismatch! expected=${targetVersion}, got=${newVersion} — rolling back`);
3477
+ rollbackInstall();
3478
+ this.jsonResponse(res, {
3479
+ ok: false,
3480
+ error: `Version mismatch: expected ${targetVersion} but downloaded ${newVersion}. npm cache may be stale — please try again.`,
3481
+ });
3482
+ return;
3483
+ }
3237
3484
 
3238
- setTimeout(() => {
3239
- this.log.info(`update-install: triggering gateway restart via SIGUSR1...`);
3240
- try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
3241
- }, 500);
3485
+ discardBackup();
3486
+ this.log.info(`update-install: success! Updated to ${newVersion}`);
3487
+ this.jsonResponseAndRestart(res, { ok: true, version: newVersion }, "update-install");
3242
3488
  });
3243
3489
  });
3244
3490
  });
@@ -3575,7 +3821,7 @@ export class ViewerServer {
3575
3821
  } else if (this.migrationState.done) {
3576
3822
  const evtName = this.migrationState.stopped ? "stopped" : "done";
3577
3823
  res.write(`event: state\ndata: ${JSON.stringify(this.migrationState)}\n\n`);
3578
- res.write(`event: ${evtName}\ndata: ${JSON.stringify({ ok: true })}\n\n`);
3824
+ res.write(`event: ${evtName}\ndata: ${JSON.stringify({ ok: this.migrationState.success, ...this.migrationState })}\n\n`);
3579
3825
  res.end();
3580
3826
  } else {
3581
3827
  res.end();
@@ -3616,19 +3862,12 @@ export class ViewerServer {
3616
3862
  this.migrationSSEClients = this.migrationSSEClients.filter(c => c !== res);
3617
3863
  });
3618
3864
 
3619
- this.migrationAbort = false;
3620
- this.migrationState = { phase: "", stored: 0, skipped: 0, merged: 0, errors: 0, processed: 0, total: 0, lastItem: null, done: false, stopped: false };
3865
+ this.migrationState = createInitialMigrationState();
3621
3866
 
3622
3867
  const send = (event: string, data: unknown) => {
3623
3868
  if (event === "item") {
3624
3869
  const d = data as any;
3625
- if (d.status === "stored") this.migrationState.stored++;
3626
- else if (d.status === "skipped" || d.status === "duplicate") this.migrationState.skipped++;
3627
- else if (d.status === "merged") this.migrationState.merged++;
3628
- else if (d.status === "error") this.migrationState.errors++;
3629
- this.migrationState.processed = d.index ?? this.migrationState.processed + 1;
3630
- this.migrationState.total = d.total ?? this.migrationState.total;
3631
- this.migrationState.lastItem = d;
3870
+ applyMigrationItemToState(this.migrationState, d);
3632
3871
  } else if (event === "phase") {
3633
3872
  this.migrationState.phase = (data as any).phase;
3634
3873
  } else if (event === "progress") {
@@ -3641,11 +3880,13 @@ export class ViewerServer {
3641
3880
  this.runMigration(send, opts.sources, concurrency).finally(() => {
3642
3881
  this.migrationRunning = false;
3643
3882
  this.migrationState.done = true;
3883
+ this.migrationState.success = computeMigrationSuccess(this.migrationState);
3884
+ const donePayload = { ok: this.migrationState.success, ...this.migrationState };
3644
3885
  if (this.migrationAbort) {
3645
3886
  this.migrationState.stopped = true;
3646
- this.broadcastSSE("stopped", { ok: true, ...this.migrationState });
3887
+ this.broadcastSSE("stopped", donePayload);
3647
3888
  } else {
3648
- this.broadcastSSE("done", { ok: true });
3889
+ this.broadcastSSE("done", donePayload);
3649
3890
  }
3650
3891
  this.migrationAbort = false;
3651
3892
  const clientsToClose = [...this.migrationSSEClients];
@@ -3742,11 +3983,24 @@ export class ViewerServer {
3742
3983
  }
3743
3984
 
3744
3985
  try {
3745
- const summary = await summarizer.summarize(row.text);
3986
+ const stepFailures: Array<"summarization" | "dedup" | "embedding"> = [];
3987
+ let summary = "";
3988
+ try {
3989
+ summary = await summarizer.summarize(row.text);
3990
+ } catch (err) {
3991
+ stepFailures.push("summarization");
3992
+ this.log.warn(`Migration summarization failed: ${err}`);
3993
+ }
3994
+ if (!summary) {
3995
+ stepFailures.push("summarization");
3996
+ summary = row.text.slice(0, 200);
3997
+ }
3998
+
3746
3999
  let embedding: number[] | null = null;
3747
4000
  try {
3748
4001
  [embedding] = await this.embedder.embed([summary]);
3749
4002
  } catch (err) {
4003
+ stepFailures.push("embedding");
3750
4004
  this.log.warn(`Migration embed failed: ${err}`);
3751
4005
  }
3752
4006
 
@@ -3765,26 +4019,31 @@ export class ViewerServer {
3765
4019
  }).filter(c => c.summary);
3766
4020
 
3767
4021
  if (candidates.length > 0) {
3768
- const dedupResult = await summarizer.judgeDedup(summary, candidates);
3769
- if (dedupResult?.action === "DUPLICATE" && dedupResult.targetIndex) {
3770
- const targetId = candidates[dedupResult.targetIndex - 1]?.chunkId;
3771
- if (targetId) {
3772
- dedupStatus = "duplicate";
3773
- dedupTarget = targetId;
3774
- dedupReason = dedupResult.reason;
3775
- }
3776
- } else if (dedupResult?.action === "UPDATE" && dedupResult.targetIndex && dedupResult.mergedSummary) {
3777
- const targetId = candidates[dedupResult.targetIndex - 1]?.chunkId;
3778
- if (targetId) {
3779
- this.store.updateChunkSummaryAndContent(targetId, dedupResult.mergedSummary, row.text);
3780
- try {
3781
- const [newEmb] = await this.embedder.embed([dedupResult.mergedSummary]);
3782
- if (newEmb) this.store.upsertEmbedding(targetId, newEmb);
3783
- } catch { /* best-effort */ }
3784
- dedupStatus = "merged";
3785
- dedupTarget = targetId;
3786
- dedupReason = dedupResult.reason;
4022
+ try {
4023
+ const dedupResult = await summarizer.judgeDedup(summary, candidates);
4024
+ if (dedupResult?.action === "DUPLICATE" && dedupResult.targetIndex) {
4025
+ const targetId = candidates[dedupResult.targetIndex - 1]?.chunkId;
4026
+ if (targetId) {
4027
+ dedupStatus = "duplicate";
4028
+ dedupTarget = targetId;
4029
+ dedupReason = dedupResult.reason;
4030
+ }
4031
+ } else if (dedupResult?.action === "UPDATE" && dedupResult.targetIndex && dedupResult.mergedSummary) {
4032
+ const targetId = candidates[dedupResult.targetIndex - 1]?.chunkId;
4033
+ if (targetId) {
4034
+ this.store.updateChunkSummaryAndContent(targetId, dedupResult.mergedSummary, row.text);
4035
+ try {
4036
+ const [newEmb] = await this.embedder.embed([dedupResult.mergedSummary]);
4037
+ if (newEmb) this.store.upsertEmbedding(targetId, newEmb);
4038
+ } catch { /* best-effort */ }
4039
+ dedupStatus = "merged";
4040
+ dedupTarget = targetId;
4041
+ dedupReason = dedupResult.reason;
4042
+ }
3787
4043
  }
4044
+ } catch (err) {
4045
+ stepFailures.push("dedup");
4046
+ this.log.warn(`Migration dedup judgment failed: ${err}`);
3788
4047
  }
3789
4048
  }
3790
4049
  }
@@ -3810,8 +4069,8 @@ export class ViewerServer {
3810
4069
  mergeCount: 0,
3811
4070
  lastHitAt: null,
3812
4071
  mergeHistory: "[]",
3813
- createdAt: normalizeTimestamp(row.updated_at),
3814
- updatedAt: normalizeTimestamp(row.updated_at),
4072
+ createdAt: Number(row.updated_at) < 1e12 ? Number(row.updated_at) * 1000 : Number(row.updated_at),
4073
+ updatedAt: Number(row.updated_at) < 1e12 ? Number(row.updated_at) * 1000 : Number(row.updated_at),
3815
4074
  };
3816
4075
 
3817
4076
  this.store.insertChunk(chunk);
@@ -3827,7 +4086,13 @@ export class ViewerServer {
3827
4086
  preview: row.text.slice(0, 120),
3828
4087
  summary: summary.slice(0, 80),
3829
4088
  source: file,
4089
+ stepFailures,
3830
4090
  });
4091
+ if (stepFailures.length > 0) {
4092
+ this.log.warn(`[MIGRATION] sqlite item imported with step failures: ${stepFailures.join(",")}`);
4093
+ } else {
4094
+ this.log.info("[MIGRATION] sqlite item imported successfully (all steps)");
4095
+ }
3831
4096
  } catch (err) {
3832
4097
  totalErrors++;
3833
4098
  send("item", {
@@ -3957,11 +4222,24 @@ export class ViewerServer {
3957
4222
  }
3958
4223
 
3959
4224
  try {
3960
- const summary = await summarizer.summarize(content);
4225
+ const stepFailures: Array<"summarization" | "dedup" | "embedding"> = [];
4226
+ let summary = "";
4227
+ try {
4228
+ summary = await summarizer.summarize(content);
4229
+ } catch (err) {
4230
+ stepFailures.push("summarization");
4231
+ this.log.warn(`Migration summarization failed: ${err}`);
4232
+ }
4233
+ if (!summary) {
4234
+ stepFailures.push("summarization");
4235
+ summary = content.slice(0, 200);
4236
+ }
4237
+
3961
4238
  let embedding: number[] | null = null;
3962
4239
  try {
3963
4240
  [embedding] = await this.embedder.embed([summary]);
3964
4241
  } catch (err) {
4242
+ stepFailures.push("embedding");
3965
4243
  this.log.warn(`Migration embed failed: ${err}`);
3966
4244
  }
3967
4245
 
@@ -3980,17 +4258,22 @@ export class ViewerServer {
3980
4258
  }).filter(c => c.summary);
3981
4259
 
3982
4260
  if (candidates.length > 0) {
3983
- const dedupResult = await summarizer.judgeDedup(summary, candidates);
3984
- if (dedupResult?.action === "DUPLICATE" && dedupResult.targetIndex) {
3985
- const targetId = candidates[dedupResult.targetIndex - 1]?.chunkId;
3986
- if (targetId) { dedupStatus = "duplicate"; dedupTarget = targetId; dedupReason = dedupResult.reason; }
3987
- } else if (dedupResult?.action === "UPDATE" && dedupResult.targetIndex && dedupResult.mergedSummary) {
3988
- const targetId = candidates[dedupResult.targetIndex - 1]?.chunkId;
3989
- if (targetId) {
3990
- this.store.updateChunkSummaryAndContent(targetId, dedupResult.mergedSummary, content);
3991
- try { const [newEmb] = await this.embedder.embed([dedupResult.mergedSummary]); if (newEmb) this.store.upsertEmbedding(targetId, newEmb); } catch { /* best-effort */ }
3992
- dedupStatus = "merged"; dedupTarget = targetId; dedupReason = dedupResult.reason;
4261
+ try {
4262
+ const dedupResult = await summarizer.judgeDedup(summary, candidates);
4263
+ if (dedupResult?.action === "DUPLICATE" && dedupResult.targetIndex) {
4264
+ const targetId = candidates[dedupResult.targetIndex - 1]?.chunkId;
4265
+ if (targetId) { dedupStatus = "duplicate"; dedupTarget = targetId; dedupReason = dedupResult.reason; }
4266
+ } else if (dedupResult?.action === "UPDATE" && dedupResult.targetIndex && dedupResult.mergedSummary) {
4267
+ const targetId = candidates[dedupResult.targetIndex - 1]?.chunkId;
4268
+ if (targetId) {
4269
+ this.store.updateChunkSummaryAndContent(targetId, dedupResult.mergedSummary, content);
4270
+ try { const [newEmb] = await this.embedder.embed([dedupResult.mergedSummary]); if (newEmb) this.store.upsertEmbedding(targetId, newEmb); } catch { /* best-effort */ }
4271
+ dedupStatus = "merged"; dedupTarget = targetId; dedupReason = dedupResult.reason;
4272
+ }
3993
4273
  }
4274
+ } catch (err) {
4275
+ stepFailures.push("dedup");
4276
+ this.log.warn(`Migration dedup judgment failed: ${err}`);
3994
4277
  }
3995
4278
  }
3996
4279
  }
@@ -4010,7 +4293,12 @@ export class ViewerServer {
4010
4293
  if (embedding && dedupStatus === "active") this.store.upsertEmbedding(chunkId, embedding);
4011
4294
 
4012
4295
  totalStored++;
4013
- send("item", { index: idx, total: totalMsgs, status: dedupStatus === "active" ? "stored" : dedupStatus, preview: content.slice(0, 120), summary: summary.slice(0, 80), source: file, agent: agentId, role: msgRole });
4296
+ send("item", { index: idx, total: totalMsgs, status: dedupStatus === "active" ? "stored" : dedupStatus, preview: content.slice(0, 120), summary: summary.slice(0, 80), source: file, agent: agentId, role: msgRole, stepFailures });
4297
+ if (stepFailures.length > 0) {
4298
+ this.log.warn(`[MIGRATION] session item imported with step failures: ${stepFailures.join(",")}`);
4299
+ } else {
4300
+ this.log.info("[MIGRATION] session item imported successfully (all steps)");
4301
+ }
4014
4302
  } catch (err) {
4015
4303
  totalErrors++;
4016
4304
  send("item", { index: idx, total: totalMsgs, status: "error", preview: content.slice(0, 120), source: file, agent: agentId, error: String(err).slice(0, 200) });
@@ -4051,7 +4339,14 @@ export class ViewerServer {
4051
4339
  }
4052
4340
 
4053
4341
  send("progress", { total: totalProcessed, processed: totalProcessed, phase: "done" });
4054
- send("summary", { totalProcessed, totalStored, totalSkipped, totalErrors });
4342
+ send("summary", {
4343
+ totalProcessed,
4344
+ totalStored,
4345
+ totalSkipped,
4346
+ totalErrors,
4347
+ success: computeMigrationSuccess(this.migrationState),
4348
+ stepFailures: this.migrationState.stepFailures,
4349
+ });
4055
4350
  }
4056
4351
 
4057
4352
  // ─── Post-processing: independent task/skill generation ───
@@ -4322,6 +4617,22 @@ export class ViewerServer {
4322
4617
  req.on("end", () => cb(body));
4323
4618
  }
4324
4619
 
4620
+ private jsonResponseAndRestart(
4621
+ res: http.ServerResponse,
4622
+ data: unknown,
4623
+ source: string,
4624
+ delayMs = 1500,
4625
+ statusCode = 200,
4626
+ ): void {
4627
+ res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
4628
+ res.end(JSON.stringify(data), () => {
4629
+ setTimeout(() => {
4630
+ this.log.info(`${source}: triggering gateway restart via SIGUSR1...`);
4631
+ try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
4632
+ }, delayMs);
4633
+ });
4634
+ }
4635
+
4325
4636
  private jsonResponse(res: http.ServerResponse, data: unknown, statusCode = 200): void {
4326
4637
  res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
4327
4638
  res.end(JSON.stringify(data));