@memtensor/memos-local-openclaw-plugin 1.0.4-beta.2 → 1.0.4-beta.20

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 (124) hide show
  1. package/.env.example +7 -0
  2. package/README.md +111 -44
  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 +36 -2
  6. package/dist/capture/index.js.map +1 -1
  7. package/dist/client/connector.d.ts +6 -2
  8. package/dist/client/connector.d.ts.map +1 -1
  9. package/dist/client/connector.js +160 -26
  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 +2 -3
  20. package/dist/config.js.map +1 -1
  21. package/dist/hub/server.d.ts +9 -0
  22. package/dist/hub/server.d.ts.map +1 -1
  23. package/dist/hub/server.js +500 -112
  24. package/dist/hub/server.js.map +1 -1
  25. package/dist/hub/user-manager.d.ts +11 -0
  26. package/dist/hub/user-manager.d.ts.map +1 -1
  27. package/dist/hub/user-manager.js +31 -3
  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/chunker.d.ts +2 -1
  33. package/dist/ingest/chunker.d.ts.map +1 -1
  34. package/dist/ingest/chunker.js +14 -10
  35. package/dist/ingest/chunker.js.map +1 -1
  36. package/dist/ingest/providers/index.d.ts.map +1 -1
  37. package/dist/ingest/providers/index.js +37 -6
  38. package/dist/ingest/providers/index.js.map +1 -1
  39. package/dist/recall/engine.d.ts.map +1 -1
  40. package/dist/recall/engine.js +96 -1
  41. package/dist/recall/engine.js.map +1 -1
  42. package/dist/shared/llm-call.d.ts +1 -0
  43. package/dist/shared/llm-call.d.ts.map +1 -1
  44. package/dist/shared/llm-call.js +84 -9
  45. package/dist/shared/llm-call.js.map +1 -1
  46. package/dist/sharing/types.d.ts +1 -1
  47. package/dist/sharing/types.d.ts.map +1 -1
  48. package/dist/skill/evolver.d.ts +4 -0
  49. package/dist/skill/evolver.d.ts.map +1 -1
  50. package/dist/skill/evolver.js +59 -5
  51. package/dist/skill/evolver.js.map +1 -1
  52. package/dist/skill/generator.d.ts +2 -0
  53. package/dist/skill/generator.d.ts.map +1 -1
  54. package/dist/skill/generator.js +45 -3
  55. package/dist/skill/generator.js.map +1 -1
  56. package/dist/skill/installer.d.ts +26 -0
  57. package/dist/skill/installer.d.ts.map +1 -1
  58. package/dist/skill/installer.js +80 -4
  59. package/dist/skill/installer.js.map +1 -1
  60. package/dist/skill/upgrader.d.ts +2 -0
  61. package/dist/skill/upgrader.d.ts.map +1 -1
  62. package/dist/skill/upgrader.js +139 -1
  63. package/dist/skill/upgrader.js.map +1 -1
  64. package/dist/skill/validator.d.ts +3 -0
  65. package/dist/skill/validator.d.ts.map +1 -1
  66. package/dist/skill/validator.js +75 -0
  67. package/dist/skill/validator.js.map +1 -1
  68. package/dist/storage/ensure-binding.d.ts +12 -0
  69. package/dist/storage/ensure-binding.d.ts.map +1 -0
  70. package/dist/storage/ensure-binding.js +53 -0
  71. package/dist/storage/ensure-binding.js.map +1 -0
  72. package/dist/storage/sqlite.d.ts +115 -20
  73. package/dist/storage/sqlite.d.ts.map +1 -1
  74. package/dist/storage/sqlite.js +458 -110
  75. package/dist/storage/sqlite.js.map +1 -1
  76. package/dist/telemetry.d.ts +12 -5
  77. package/dist/telemetry.d.ts.map +1 -1
  78. package/dist/telemetry.js +156 -40
  79. package/dist/telemetry.js.map +1 -1
  80. package/dist/tools/memory-search.d.ts +3 -1
  81. package/dist/tools/memory-search.d.ts.map +1 -1
  82. package/dist/tools/memory-search.js +3 -1
  83. package/dist/tools/memory-search.js.map +1 -1
  84. package/dist/types.d.ts +11 -2
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/types.js +4 -0
  87. package/dist/types.js.map +1 -1
  88. package/dist/viewer/html.d.ts.map +1 -1
  89. package/dist/viewer/html.js +2952 -910
  90. package/dist/viewer/html.js.map +1 -1
  91. package/dist/viewer/server.d.ts +39 -8
  92. package/dist/viewer/server.d.ts.map +1 -1
  93. package/dist/viewer/server.js +1198 -227
  94. package/dist/viewer/server.js.map +1 -1
  95. package/index.ts +774 -74
  96. package/openclaw.plugin.json +2 -2
  97. package/package.json +3 -2
  98. package/scripts/postinstall.cjs +1 -1
  99. package/skill/memos-memory-guide/SKILL.md +64 -26
  100. package/src/capture/index.ts +40 -1
  101. package/src/client/connector.ts +161 -28
  102. package/src/client/hub.ts +18 -0
  103. package/src/client/skill-sync.ts +14 -0
  104. package/src/config.ts +2 -3
  105. package/src/hub/server.ts +481 -107
  106. package/src/hub/user-manager.ts +48 -8
  107. package/src/index.ts +10 -2
  108. package/src/ingest/chunker.ts +19 -13
  109. package/src/ingest/providers/index.ts +41 -7
  110. package/src/recall/engine.ts +89 -1
  111. package/src/shared/llm-call.ts +99 -10
  112. package/src/sharing/types.ts +1 -1
  113. package/src/skill/evolver.ts +63 -6
  114. package/src/skill/generator.ts +44 -5
  115. package/src/skill/installer.ts +107 -4
  116. package/src/skill/upgrader.ts +139 -1
  117. package/src/skill/validator.ts +79 -0
  118. package/src/storage/ensure-binding.ts +52 -0
  119. package/src/storage/sqlite.ts +498 -137
  120. package/src/telemetry.ts +172 -41
  121. package/src/tools/memory-search.ts +2 -1
  122. package/src/types.ts +12 -2
  123. package/src/viewer/html.ts +2952 -910
  124. package/src/viewer/server.ts +1109 -212
package/index.ts CHANGED
@@ -9,19 +9,22 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
9
9
  import { Type } from "@sinclair/typebox";
10
10
  import * as fs from "fs";
11
11
  import * as path from "path";
12
+ import { fileURLToPath } from "url";
12
13
  import { buildContext } from "./src/config";
13
14
  import type { HostModelsConfig } from "./src/openclaw-api";
15
+ import { ensureSqliteBinding } from "./src/storage/ensure-binding";
14
16
  import { SqliteStore } from "./src/storage/sqlite";
15
17
  import { Embedder } from "./src/embedding";
16
18
  import { IngestWorker } from "./src/ingest/worker";
17
19
  import { RecallEngine } from "./src/recall/engine";
18
20
  import { captureMessages, stripInboundMetadata } from "./src/capture";
19
21
  import { DEFAULTS } from "./src/types";
22
+ import type { SearchHit } from "./src/types";
20
23
  import { ViewerServer } from "./src/viewer/server";
21
24
  import { HubServer } from "./src/hub/server";
22
25
  import { hubGetMemoryDetail, hubRequestJson, hubSearchMemories, hubSearchSkills, resolveHubClient } from "./src/client/hub";
23
26
  import { getHubStatus, connectToHub } from "./src/client/connector";
24
- import { fetchHubSkillBundle, publishSkillBundleToHub, restoreSkillBundleFromHub } from "./src/client/skill-sync";
27
+ import { fetchHubSkillBundle, publishSkillBundleToHub, restoreSkillBundleFromHub, unpublishSkillBundleFromHub } from "./src/client/skill-sync";
25
28
  import { SkillEvolver } from "./src/skill/evolver";
26
29
  import { SkillInstaller } from "./src/skill/installer";
27
30
  import { Summarizer } from "./src/ingest/providers";
@@ -81,13 +84,20 @@ const memosLocalPlugin = {
81
84
 
82
85
  register(api: OpenClawPluginApi) {
83
86
  // ─── Ensure better-sqlite3 native module is available ───
84
- const pluginDir = path.dirname(new URL(import.meta.url).pathname);
87
+ const pluginDir = path.dirname(fileURLToPath(import.meta.url));
88
+
89
+ function normalizeFsPath(p: string): string {
90
+ return path.resolve(p).replace(/\\/g, "/").toLowerCase();
91
+ }
92
+
85
93
  let sqliteReady = false;
86
94
 
87
95
  function trySqliteLoad(): boolean {
88
96
  try {
89
97
  const resolved = require.resolve("better-sqlite3", { paths: [pluginDir] });
90
- if (!resolved.startsWith(pluginDir)) {
98
+ const resolvedNorm = normalizeFsPath(resolved);
99
+ const pluginNorm = normalizeFsPath(pluginDir);
100
+ if (!resolvedNorm.startsWith(pluginNorm + "/") && resolvedNorm !== pluginNorm) {
91
101
  api.logger.warn(`memos-local: better-sqlite3 resolved outside plugin dir: ${resolved}`);
92
102
  return false;
93
103
  }
@@ -170,7 +180,7 @@ const memosLocalPlugin = {
170
180
  }
171
181
 
172
182
  let pluginCfg = (api.pluginConfig ?? {}) as Record<string, unknown>;
173
- const stateDir = api.resolvePath("~/.openclaw");
183
+ const stateDir = process.env.OPENCLAW_STATE_DIR || api.resolvePath("~/.openclaw");
174
184
 
175
185
  // Fallback: read config from file if not provided by OpenClaw
176
186
  const configPath = path.join(stateDir, "state", "memos-local", "config.json");
@@ -196,6 +206,8 @@ const memosLocalPlugin = {
196
206
  error: (msg: string) => api.logger.warn(`[error] ${msg}`),
197
207
  }, hostModels);
198
208
 
209
+ ensureSqliteBinding(ctx.log);
210
+
199
211
  const store = new SqliteStore(ctx.config.storage!.dbPath!, ctx.log);
200
212
  const embedder = new Embedder(ctx.config.embedding, ctx.log, ctx.openclawAPI);
201
213
  const worker = new IngestWorker(store, embedder, ctx);
@@ -205,6 +217,7 @@ const memosLocalPlugin = {
205
217
  const workspaceDir = api.resolvePath("~/.openclaw/workspace");
206
218
  const skillCtx = { ...ctx, workspaceDir };
207
219
  const skillEvolver = new SkillEvolver(store, engine, skillCtx);
220
+ skillEvolver.onSkillEvolved = (name, type) => telemetry.trackSkillEvolved(name, type);
208
221
  const skillInstaller = new SkillInstaller(store, skillCtx);
209
222
 
210
223
  let pluginVersion = "0.0.0";
@@ -269,6 +282,18 @@ const memosLocalPlugin = {
269
282
  // Falls back to "main" when no hook has fired yet (single-agent setups).
270
283
  let currentAgentId = "main";
271
284
 
285
+ // ─── Check allowPromptInjection policy ───
286
+ // When allowPromptInjection=false, the prompt mutation fields (such as prependContext) in the hook return value
287
+ // will be stripped by the framework. Skip auto-recall to avoid unnecessary LLM/embedding calls.
288
+ const pluginEntry = (api.config as any)?.plugins?.entries?.[api.id];
289
+ const allowPromptInjection = pluginEntry?.hooks?.allowPromptInjection !== false;
290
+ if (!allowPromptInjection) {
291
+ api.logger.info("memos-local: allowPromptInjection=false, auto-recall disabled");
292
+ }
293
+ else {
294
+ api.logger.info("memos-local: allowPromptInjection=true, auto-recall enabled");
295
+ }
296
+
272
297
  const trackTool = (toolName: string, fn: (...args: any[]) => Promise<any>) =>
273
298
  async (...args: any[]) => {
274
299
  const t0 = performance.now();
@@ -280,6 +305,7 @@ const memosLocalPlugin = {
280
305
  return result;
281
306
  } catch (e) {
282
307
  ok = false;
308
+ telemetry.trackError(toolName, (e as Error)?.name ?? "unknown");
283
309
  throw e;
284
310
  } finally {
285
311
  const dur = performance.now() - t0;
@@ -293,6 +319,21 @@ const memosLocalPlugin = {
293
319
  candidates: det.candidates,
294
320
  filtered: det.hits ?? det.filtered ?? [],
295
321
  });
322
+ } else if (det && det.local && det.hub) {
323
+ const localHits = det.local?.hits ?? [];
324
+ const hubHits = (det.hub?.hits ?? []).map((h: any) => ({
325
+ score: h.score ?? 0,
326
+ role: h.source?.role ?? h.role ?? "assistant",
327
+ summary: h.summary ?? "",
328
+ original_excerpt: h.excerpt ?? h.summary ?? "",
329
+ origin: "hub-remote",
330
+ ownerName: h.ownerName ?? "",
331
+ groupName: h.groupName ?? "",
332
+ }));
333
+ outputText = JSON.stringify({
334
+ candidates: [...localHits, ...hubHits],
335
+ filtered: [...localHits, ...hubHits],
336
+ });
296
337
  } else {
297
338
  outputText = result?.content?.[0]?.text ?? JSON.stringify(result ?? "");
298
339
  }
@@ -301,6 +342,93 @@ const memosLocalPlugin = {
301
342
  }
302
343
  };
303
344
 
345
+ const getCurrentOwner = () => `agent:${currentAgentId}`;
346
+ const resolveMemorySearchScope = (scope?: string): "local" | "group" | "all" =>
347
+ scope === "group" || scope === "all" ? scope : "local";
348
+ const resolveMemoryShareTarget = (target?: string): "agents" | "hub" | "both" =>
349
+ target === "hub" || target === "both" ? target : "agents";
350
+ const resolveMemoryUnshareTarget = (target?: string): "agents" | "hub" | "all" =>
351
+ target === "agents" || target === "hub" ? target : "all";
352
+ const resolveSkillPublishTarget = (target?: string, scope?: string): "agents" | "hub" => {
353
+ if (target === "hub") return "hub";
354
+ if (target === "agents") return "agents";
355
+ return scope === "public" || scope === "group" ? "hub" : "agents";
356
+ };
357
+ const resolveSkillHubVisibility = (visibility?: string, scope?: string): "public" | "group" =>
358
+ visibility === "group" || scope === "group" ? "group" : "public";
359
+ const resolveSkillUnpublishTarget = (target?: string): "agents" | "hub" | "all" =>
360
+ target === "hub" || target === "all" ? target : "agents";
361
+
362
+ const shareMemoryToHub = async (
363
+ chunkId: string,
364
+ input?: { visibility?: "public" | "group"; groupId?: string; hubAddress?: string; userToken?: string },
365
+ ): Promise<{ memoryId: string; visibility: "public" | "group"; groupId: string | null }> => {
366
+ const chunk = store.getChunk(chunkId);
367
+ if (!chunk) {
368
+ throw new Error(`Memory not found: ${chunkId}`);
369
+ }
370
+
371
+ const visibility = input?.visibility === "group" ? "group" : "public";
372
+ const groupId = visibility === "group" ? (input?.groupId ?? null) : null;
373
+ const hubClient = await resolveHubClient(store, ctx, { hubAddress: input?.hubAddress, userToken: input?.userToken });
374
+ const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/share", {
375
+ method: "POST",
376
+ body: JSON.stringify({
377
+ memory: {
378
+ sourceChunkId: chunk.id,
379
+ role: chunk.role,
380
+ content: chunk.content,
381
+ summary: chunk.summary,
382
+ kind: chunk.kind,
383
+ groupId,
384
+ visibility,
385
+ },
386
+ }),
387
+ }) as { memoryId?: string; visibility?: "public" | "group" };
388
+
389
+ const memoryId = response?.memoryId ?? `${chunk.id}-hub`;
390
+
391
+ // Hub role: full hub_memories row for local recall/embeddings. Client: metadata only (team_shared_chunks) for UI.
392
+ if (ctx.config.sharing?.role === "hub") {
393
+ const now = Date.now();
394
+ const existing = store.getHubMemoryBySource(hubClient.userId, chunk.id);
395
+ store.upsertHubMemory({
396
+ id: memoryId,
397
+ sourceChunkId: chunk.id,
398
+ sourceUserId: hubClient.userId,
399
+ role: chunk.role,
400
+ content: chunk.content,
401
+ summary: chunk.summary ?? "",
402
+ kind: chunk.kind,
403
+ groupId,
404
+ visibility,
405
+ createdAt: existing?.createdAt ?? now,
406
+ updatedAt: now,
407
+ });
408
+ } else if (ctx.config.sharing?.enabled && hubClient.userId) {
409
+ store.upsertTeamSharedChunk(chunk.id, { hubMemoryId: memoryId, visibility, groupId });
410
+ }
411
+
412
+ return { memoryId, visibility, groupId };
413
+ };
414
+
415
+ const unshareMemoryFromHub = async (
416
+ chunkId: string,
417
+ input?: { hubAddress?: string; userToken?: string },
418
+ ): Promise<void> => {
419
+ const chunk = store.getChunk(chunkId);
420
+ if (!chunk) {
421
+ throw new Error(`Memory not found: ${chunkId}`);
422
+ }
423
+ const hubClient = await resolveHubClient(store, ctx, { hubAddress: input?.hubAddress, userToken: input?.userToken });
424
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/unshare", {
425
+ method: "POST",
426
+ body: JSON.stringify({ sourceChunkId: chunk.id }),
427
+ });
428
+ store.deleteHubMemoryBySource(hubClient.userId, chunk.id);
429
+ store.deleteTeamSharedChunk(chunk.id);
430
+ };
431
+
304
432
  // ─── Tool: memory_search ───
305
433
 
306
434
  api.registerTool(
@@ -309,24 +437,46 @@ const memosLocalPlugin = {
309
437
  label: "Memory Search",
310
438
  description:
311
439
  "Search long-term conversation memory for past conversations, user preferences, decisions, and experiences. " +
312
- "Relevant memories are automatically injected at the start of each turn, but call this tool when you need " +
313
- "to search with a different query or the auto-recalled context is insufficient. " +
314
- "Pass only a short natural-language query (2-5 key words).",
440
+ "Use scope='local' for this agent plus local shared memories, or scope='group'/'all' to include Hub-shared memories. " +
441
+ "Supports optional maxResults, minScore, and role filtering when you need tighter control.",
315
442
  parameters: Type.Object({
316
443
  query: Type.String({ description: "Short natural language search query (2-5 key words)" }),
444
+ scope: Type.Optional(Type.String({ description: "Search scope: 'local' (default), 'group', or 'all'. Use group/all to include Hub-shared memories." })),
445
+ maxResults: Type.Optional(Type.Number({ description: "Maximum results to return. Default 10, max 20." })),
446
+ minScore: Type.Optional(Type.Number({ description: "Minimum score threshold for local recall. Default 0.45, floor 0.35." })),
447
+ role: Type.Optional(Type.String({ description: "Optional local role filter: 'user', 'assistant', 'tool', or 'system'." })),
448
+ hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override for group/all search." })),
449
+ userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override for group/all search." })),
317
450
  }),
318
451
  execute: trackTool("memory_search", async (_toolCallId: any, params: any) => {
319
- const { query } = params as { query: string };
320
- const role = undefined;
321
- const minScore = undefined;
322
- const searchScope = "local";
323
- const searchLimit = 10;
324
- const hubAddress: string | undefined = undefined;
325
- const userToken: string | undefined = undefined;
452
+ const {
453
+ query,
454
+ scope: rawScope,
455
+ maxResults,
456
+ minScore: rawMinScore,
457
+ role: rawRole,
458
+ hubAddress,
459
+ userToken,
460
+ } = params as {
461
+ query: string;
462
+ scope?: string;
463
+ maxResults?: number;
464
+ minScore?: number;
465
+ role?: string;
466
+ hubAddress?: string;
467
+ userToken?: string;
468
+ };
469
+ const role = rawRole === "user" || rawRole === "assistant" || rawRole === "tool" || rawRole === "system" ? rawRole : undefined;
470
+ const minScore = typeof rawMinScore === "number" ? Math.max(0.35, Math.min(1, rawMinScore)) : undefined;
471
+ let searchScope = resolveMemorySearchScope(rawScope);
472
+ if (searchScope === "local" && ctx.config?.sharing?.enabled) {
473
+ searchScope = "all";
474
+ }
475
+ const searchLimit = typeof maxResults === "number" ? Math.max(1, Math.min(20, Math.round(maxResults))) : 10;
326
476
 
327
477
  const agentId = currentAgentId;
328
- const ownerFilter = [`agent:${agentId}`, "public"];
329
- const effectiveMaxResults = 10;
478
+ const ownerFilter = [getCurrentOwner(), "public"];
479
+ const effectiveMaxResults = searchLimit;
330
480
  ctx.log.debug(`memory_search query="${query}" maxResults=${effectiveMaxResults} minScore=${minScore ?? 0.45} role=${role ?? "all"} owner=agent:${agentId}`);
331
481
  const result = await engine.search({ query, maxResults: effectiveMaxResults, minScore, role, ownerFilter });
332
482
  ctx.log.debug(`memory_search raw candidates: ${result.hits.length}`);
@@ -337,9 +487,10 @@ const memosLocalPlugin = {
337
487
  score: h.score,
338
488
  summary: h.summary,
339
489
  original_excerpt: (h.original_excerpt ?? "").slice(0, 200),
490
+ origin: h.origin || "local",
340
491
  }));
341
492
 
342
- if (result.hits.length === 0) {
493
+ if (result.hits.length === 0 && searchScope === "local") {
343
494
  return {
344
495
  content: [{ type: "text", text: result.meta.note ?? "No relevant memories found." }],
345
496
  details: { candidates: [], meta: result.meta },
@@ -363,11 +514,13 @@ const memosLocalPlugin = {
363
514
  const indexSet = new Set(filterResult.relevant);
364
515
  filteredHits = result.hits.filter((_, i) => indexSet.has(i + 1));
365
516
  ctx.log.debug(`memory_search LLM filter: ${result.hits.length} → ${filteredHits.length} hits, sufficient=${sufficient}`);
366
- } else {
517
+ } else if (searchScope === "local") {
367
518
  return {
368
519
  content: [{ type: "text", text: "No relevant memories found for this query." }],
369
520
  details: { candidates: rawCandidates, filtered: [], meta: result.meta },
370
521
  };
522
+ } else {
523
+ filteredHits = [];
371
524
  }
372
525
  }
373
526
 
@@ -389,29 +542,79 @@ const memosLocalPlugin = {
389
542
  role: h.source.role,
390
543
  score: h.score,
391
544
  summary: h.summary,
545
+ origin: h.origin || "local",
392
546
  };
393
547
  });
394
548
 
395
549
  if (searchScope !== "local") {
396
550
  const hub = await hubSearchMemories(store, ctx, { query, maxResults: searchLimit, scope: searchScope as any, hubAddress, userToken }).catch(() => ({ hits: [], meta: { totalCandidates: 0, searchedGroups: [], includedPublic: searchScope === "all" } }));
551
+
552
+ let filteredHubHits = hub.hits;
553
+ if (hub.hits.length > 0) {
554
+ const hubCandidates = hub.hits.map((h, i) => ({
555
+ index: filteredHits.length + i + 1,
556
+ role: (h.source?.role || "assistant") as string,
557
+ content: (h.summary || h.excerpt || "").slice(0, 300),
558
+ time: h.source?.ts ? new Date(h.source.ts).toISOString().slice(0, 16) : "",
559
+ }));
560
+ const localCandidatesForMerge = filteredHits.map((h, i) => ({
561
+ index: i + 1,
562
+ role: h.source.role,
563
+ content: (h.original_excerpt ?? "").slice(0, 300),
564
+ time: h.source.ts ? new Date(h.source.ts).toISOString().slice(0, 16) : "",
565
+ }));
566
+ const mergedCandidates = [...localCandidatesForMerge, ...hubCandidates];
567
+ const mergedFilter = await summarizer.filterRelevant(query, mergedCandidates);
568
+ if (mergedFilter !== null && mergedFilter.relevant.length > 0) {
569
+ const relevantSet = new Set(mergedFilter.relevant);
570
+ const hubStartIdx = filteredHits.length + 1;
571
+ filteredHits = filteredHits.filter((_, i) => relevantSet.has(i + 1));
572
+ filteredHubHits = hub.hits.filter((_, i) => relevantSet.has(hubStartIdx + i));
573
+ ctx.log.debug(`memory_search LLM filter (merged): local ${localCandidatesForMerge.length}→${filteredHits.length}, hub ${hub.hits.length}→${filteredHubHits.length}`);
574
+ }
575
+ }
576
+
577
+ const originLabel = (h: SearchHit) => {
578
+ if (h.origin === "hub-memory") return " [团队缓存]";
579
+ if (h.origin === "local-shared") return " [本机共享]";
580
+ return "";
581
+ };
397
582
  const localText = filteredHits.length > 0
398
583
  ? filteredHits.map((h, i) => {
399
584
  const excerpt = h.original_excerpt.length > 220 ? h.original_excerpt.slice(0, 217) + "..." : h.original_excerpt;
400
- return `${i + 1}. [${h.source.role}] ${excerpt}`;
585
+ return `${i + 1}. [${h.source.role}]${originLabel(h)} ${excerpt}`;
401
586
  }).join("\n")
402
587
  : "(none)";
403
- const hubText = hub.hits.length > 0
404
- ? hub.hits.map((h, i) => `${i + 1}. [${h.ownerName}] ${h.summary}${h.groupName ? ` (${h.groupName})` : ""}`).join("\n")
588
+ const hubText = filteredHubHits.length > 0
589
+ ? filteredHubHits.map((h, i) => `${i + 1}. [${h.ownerName}] [团队] ${h.summary}${h.groupName ? ` (${h.groupName})` : ""}`).join("\n")
405
590
  : "(none)";
406
591
 
592
+ const localDetailsFiltered = filteredHits.map((h) => {
593
+ let effectiveTaskId = h.taskId;
594
+ if (effectiveTaskId) {
595
+ const t = store.getTask(effectiveTaskId);
596
+ if (t && t.status === "skipped") effectiveTaskId = null;
597
+ }
598
+ return {
599
+ ref: h.ref,
600
+ chunkId: h.ref.chunkId,
601
+ taskId: effectiveTaskId,
602
+ skillId: h.skillId,
603
+ role: h.source.role,
604
+ score: h.score,
605
+ summary: h.summary,
606
+ origin: h.origin,
607
+ };
608
+ });
609
+
407
610
  return {
408
611
  content: [{
409
612
  type: "text",
410
613
  text: `Local results:\n${localText}\n\nHub results:\n${hubText}`,
411
614
  }],
412
615
  details: {
413
- local: { hits: localDetailsHits, meta: result.meta },
414
- hub,
616
+ local: { hits: localDetailsFiltered, meta: result.meta },
617
+ hub: { ...hub, hits: filteredHubHits },
415
618
  },
416
619
  };
417
620
  }
@@ -423,9 +626,15 @@ const memosLocalPlugin = {
423
626
  };
424
627
  }
425
628
 
629
+ const originTag = (o?: string) => {
630
+ if (o === "local-shared") return " [本机共享]";
631
+ if (o === "hub-memory") return " [团队缓存]";
632
+ if (o === "hub-remote") return " [团队]";
633
+ return "";
634
+ };
426
635
  const lines = filteredHits.map((h, i) => {
427
636
  const excerpt = h.original_excerpt;
428
- const parts = [`${i + 1}. [${h.source.role}]`];
637
+ const parts = [`${i + 1}. [${h.source.role}]${originTag(h.origin)}`];
429
638
  if (excerpt) parts.push(` ${excerpt}`);
430
639
  parts.push(` chunkId="${h.ref.chunkId}"`);
431
640
  if (h.taskId) {
@@ -480,6 +689,7 @@ const memosLocalPlugin = {
480
689
  score: h.score,
481
690
  summary: h.summary,
482
691
  original_excerpt: (h.original_excerpt ?? "").slice(0, 200),
692
+ origin: h.origin || "local",
483
693
  };
484
694
  }),
485
695
  meta: result.meta,
@@ -843,7 +1053,9 @@ ${detail.content}`,
843
1053
  {
844
1054
  name: "network_team_info",
845
1055
  label: "Network Team Info",
846
- description: "Show current Hub connection status, signed-in user, role, and group memberships.",
1056
+ description:
1057
+ "Show current Hub connection status, signed-in user, role, and group memberships. " +
1058
+ "Use this as a preflight check before any Hub share/unshare or Hub pull operation.",
847
1059
  parameters: Type.Object({}),
848
1060
  execute: trackTool("network_team_info", async () => {
849
1061
  const status = await getHubStatus(store, ctx.config);
@@ -927,10 +1139,31 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
927
1139
  };
928
1140
  }
929
1141
 
1142
+ const manifest = skillInstaller.getCompanionManifest(resolvedSkillId);
1143
+ let footer = "\n\n---\n";
1144
+
1145
+ if (manifest && manifest.hasCompanionFiles) {
1146
+ const fileSummary = manifest.files
1147
+ .filter(f => f.type !== "eval")
1148
+ .map(f => `\`${f.relativePath}\``)
1149
+ .join(", ");
1150
+ footer += `**Companion files available:** ${fileSummary}\n`;
1151
+ footer += `→ call \`skill_files(skillId="${resolvedSkillId}")\` to list all files\n`;
1152
+ footer += `→ call \`skill_file_get(skillId="${resolvedSkillId}", path="...")\` to read a specific file\n`;
1153
+ if (manifest.installMode === "install_recommended") {
1154
+ footer += `→ **Recommended:** call \`skill_install(skillId="${resolvedSkillId}")\` for persistent workspace access (many/large files)\n`;
1155
+ }
1156
+ if (manifest.installed && manifest.installedPath) {
1157
+ footer += `> Already installed at: ${manifest.installedPath}/\n`;
1158
+ }
1159
+ } else {
1160
+ footer += `To install this skill for persistent use: call skill_install(skillId="${resolvedSkillId}")`;
1161
+ }
1162
+
930
1163
  return {
931
1164
  content: [{
932
1165
  type: "text",
933
- text: `## Skill: ${skill.name} (v${skill.version})\n\n${sv.content}\n\n---\nTo install this skill for persistent use: call skill_install(skillId="${resolvedSkillId}")`,
1166
+ text: `## Skill: ${skill.name} (v${skill.version})\n\n${sv.content}${footer}`,
934
1167
  }],
935
1168
  details: {
936
1169
  skillId: skill.id,
@@ -938,6 +1171,8 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
938
1171
  version: skill.version,
939
1172
  status: skill.status,
940
1173
  installed: skill.installed,
1174
+ companionFiles: manifest?.hasCompanionFiles ?? false,
1175
+ installMode: manifest?.installMode ?? "inline",
941
1176
  },
942
1177
  };
943
1178
  }),
@@ -973,9 +1208,116 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
973
1208
  { name: "skill_install" },
974
1209
  );
975
1210
 
1211
+ // ─── Tool: skill_files ───
1212
+
1213
+ api.registerTool(
1214
+ {
1215
+ name: "skill_files",
1216
+ label: "List Skill Companion Files",
1217
+ description:
1218
+ "List companion files (scripts, references, evals) for a skill. " +
1219
+ "Use this after skill_get to see what additional files are available. " +
1220
+ "Returns file names, sizes, and whether the skill recommends installation.",
1221
+ parameters: Type.Object({
1222
+ skillId: Type.String({ description: "The skill_id to inspect" }),
1223
+ }),
1224
+ execute: trackTool("skill_files", async (_toolCallId: any, params: any) => {
1225
+ const { skillId } = params as { skillId: string };
1226
+ ctx.log.debug(`skill_files called for skill=${skillId}`);
1227
+
1228
+ const manifest = skillInstaller.getCompanionManifest(skillId);
1229
+ if (!manifest) {
1230
+ return {
1231
+ content: [{ type: "text", text: `Skill not found: ${skillId}` }],
1232
+ details: { error: "not_found" },
1233
+ };
1234
+ }
1235
+
1236
+ if (!manifest.hasCompanionFiles) {
1237
+ return {
1238
+ content: [{ type: "text", text: "This skill has no companion files (scripts, references). The SKILL.md from skill_get contains everything." }],
1239
+ details: manifest,
1240
+ };
1241
+ }
1242
+
1243
+ const lines: string[] = [`## Companion Files (${manifest.files.length} files, ${Math.round(manifest.totalSize / 1024)}KB total)\n`];
1244
+ if (manifest.scriptsCount > 0) {
1245
+ lines.push(`### Scripts (${manifest.scriptsCount})`);
1246
+ for (const f of manifest.files.filter(f => f.type === "script")) {
1247
+ lines.push(`- \`${f.relativePath}\` (${f.size} bytes) → call \`skill_file_get(skillId="${skillId}", path="${f.relativePath}")\``);
1248
+ }
1249
+ }
1250
+ if (manifest.referencesCount > 0) {
1251
+ lines.push(`\n### References (${manifest.referencesCount})`);
1252
+ for (const f of manifest.files.filter(f => f.type === "reference")) {
1253
+ lines.push(`- \`${f.relativePath}\` (${f.size} bytes) → call \`skill_file_get(skillId="${skillId}", path="${f.relativePath}")\``);
1254
+ }
1255
+ }
1256
+ if (manifest.evalsCount > 0) {
1257
+ lines.push(`\n### Evals (${manifest.evalsCount})`);
1258
+ for (const f of manifest.files.filter(f => f.type === "eval")) {
1259
+ lines.push(`- \`${f.relativePath}\` (${f.size} bytes)`);
1260
+ }
1261
+ }
1262
+
1263
+ if (manifest.installMode === "install_recommended") {
1264
+ lines.push(`\n> **Recommendation:** This skill has many/large companion files. Consider \`skill_install(skillId="${skillId}")\` for persistent workspace access.`);
1265
+ }
1266
+ if (manifest.installed && manifest.installedPath) {
1267
+ lines.push(`\n> **Installed at:** ${manifest.installedPath}/`);
1268
+ }
1269
+
1270
+ return {
1271
+ content: [{ type: "text", text: lines.join("\n") }],
1272
+ details: manifest,
1273
+ };
1274
+ }),
1275
+ },
1276
+ { name: "skill_files" },
1277
+ );
1278
+
1279
+ // ─── Tool: skill_file_get ───
1280
+
1281
+ api.registerTool(
1282
+ {
1283
+ name: "skill_file_get",
1284
+ label: "Get Skill Companion File",
1285
+ description:
1286
+ "Read the content of a specific companion file (script, reference) from a skill. " +
1287
+ "Use after skill_files to retrieve a script or reference document. " +
1288
+ "Pass the relative path like 'scripts/deploy.sh' or 'references/api-notes.md'.",
1289
+ parameters: Type.Object({
1290
+ skillId: Type.String({ description: "The skill_id" }),
1291
+ path: Type.String({ description: "Relative path within the skill, e.g. 'scripts/deploy.sh'" }),
1292
+ }),
1293
+ execute: trackTool("skill_file_get", async (_toolCallId: any, params: any) => {
1294
+ const { skillId, path: filePath } = params as { skillId: string; path: string };
1295
+ ctx.log.debug(`skill_file_get called for skill=${skillId} path=${filePath}`);
1296
+
1297
+ const result = skillInstaller.readCompanionFile(skillId, filePath);
1298
+ if ("error" in result) {
1299
+ return {
1300
+ content: [{ type: "text", text: `Error: ${result.error}` }],
1301
+ details: result,
1302
+ };
1303
+ }
1304
+
1305
+ const ext = filePath.split(".").pop() || "";
1306
+ const lang = { sh: "bash", py: "python", ts: "typescript", js: "javascript", json: "json", md: "markdown", yml: "yaml", yaml: "yaml" }[ext] || "";
1307
+
1308
+ return {
1309
+ content: [{ type: "text", text: `## ${filePath}\n\n\`\`\`${lang}\n${result.content}\n\`\`\`` }],
1310
+ details: { path: filePath, size: result.size },
1311
+ };
1312
+ }),
1313
+ },
1314
+ { name: "skill_file_get" },
1315
+ );
1316
+
976
1317
  // ─── Tool: memory_viewer ───
977
1318
 
978
- const viewerPort = (pluginCfg as any).viewerPort ?? 18799;
1319
+ const gatewayPort = (api.config as any)?.gateway?.port ?? 18789;
1320
+ const viewerPort = (pluginCfg as any).viewerPort ?? (gatewayPort + 10);
979
1321
 
980
1322
  api.registerTool(
981
1323
  {
@@ -988,6 +1330,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
988
1330
  parameters: Type.Object({}),
989
1331
  execute: trackTool("memory_viewer", async () => {
990
1332
  ctx.log.debug(`memory_viewer called`);
1333
+ telemetry.trackViewerOpened();
991
1334
  const url = `http://127.0.0.1:${viewerPort}`;
992
1335
  return {
993
1336
  content: [
@@ -1018,12 +1361,13 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1018
1361
  api.registerTool(
1019
1362
  {
1020
1363
  name: "memory_write_public",
1021
- label: "Write Public Memory",
1364
+ label: "Write Local Shared Memory",
1022
1365
  description:
1023
- "Write a piece of information to public memory. Public memories are visible to all agents during memory_search. " +
1024
- "Use this for shared knowledge, team decisions, or cross-agent coordination information.",
1366
+ "Write a piece of information to local shared memory for all agents in this OpenClaw workspace. " +
1367
+ "Use this when you are creating a new shared note from scratch. This does not publish to Hub. " +
1368
+ "If you already have a memory chunk and want to expose it, use memory_share instead.",
1025
1369
  parameters: Type.Object({
1026
- content: Type.String({ description: "The content to write to public memory" }),
1370
+ content: Type.String({ description: "The content to write to local shared memory" }),
1027
1371
  summary: Type.Optional(Type.String({ description: "Optional short summary of the content" })),
1028
1372
  }),
1029
1373
  execute: trackTool("memory_write_public", async (_toolCallId: any, params: any) => {
@@ -1068,7 +1412,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1068
1412
  }
1069
1413
 
1070
1414
  return {
1071
- content: [{ type: "text", text: `Public memory written successfully (id: ${chunkId}).` }],
1415
+ content: [{ type: "text", text: `Memory shared to local agents successfully (id: ${chunkId}).` }],
1072
1416
  details: { chunkId, owner: "public" },
1073
1417
  };
1074
1418
  }),
@@ -1076,6 +1420,164 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1076
1420
  { name: "memory_write_public" },
1077
1421
  );
1078
1422
 
1423
+ api.registerTool(
1424
+ {
1425
+ name: "memory_share",
1426
+ label: "Share Memory",
1427
+ description:
1428
+ "Share an existing memory either with local OpenClaw agents, to the Hub team, or to both targets. " +
1429
+ "Use this only for an existing chunkId. Use target='agents' for local multi-agent sharing, target='hub' for team sharing, or target='both' for both. " +
1430
+ "If you need to create a brand new shared memory instead of exposing an existing one, use memory_write_public.",
1431
+ parameters: Type.Object({
1432
+ chunkId: Type.String({ description: "Existing local memory chunk ID to share" }),
1433
+ target: Type.Optional(Type.String({ description: "Share target: 'agents' (default), 'hub', or 'both'" })),
1434
+ visibility: Type.Optional(Type.String({ description: "Hub visibility when target includes hub: 'public' (default) or 'group'" })),
1435
+ groupId: Type.Optional(Type.String({ description: "Optional Hub group ID when visibility='group'" })),
1436
+ hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override" })),
1437
+ userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override" })),
1438
+ }),
1439
+ execute: trackTool("memory_share", async (_toolCallId: any, params: any) => {
1440
+ const {
1441
+ chunkId,
1442
+ target: rawTarget,
1443
+ visibility: rawVisibility,
1444
+ groupId,
1445
+ hubAddress,
1446
+ userToken,
1447
+ } = params as {
1448
+ chunkId: string;
1449
+ target?: string;
1450
+ visibility?: string;
1451
+ groupId?: string;
1452
+ hubAddress?: string;
1453
+ userToken?: string;
1454
+ };
1455
+
1456
+ const chunk = store.getChunk(chunkId);
1457
+ if (!chunk) {
1458
+ return { content: [{ type: "text", text: `Memory not found: ${chunkId}` }], details: { error: "not_found", chunkId } };
1459
+ }
1460
+
1461
+ const target = resolveMemoryShareTarget(rawTarget);
1462
+ const visibility = rawVisibility === "group" ? "group" : "public";
1463
+ const details: Record<string, unknown> = { chunkId, target };
1464
+ const messages: string[] = [];
1465
+
1466
+ if (target === "agents" || target === "both") {
1467
+ const local = store.markMemorySharedLocally(chunkId);
1468
+ if (!local.ok) {
1469
+ return { content: [{ type: "text", text: `Failed to share memory ${chunkId} to local agents.` }], details: { error: local.reason ?? "local_share_failed", chunkId, target } };
1470
+ }
1471
+ details.local = {
1472
+ shared: true,
1473
+ owner: local.owner,
1474
+ originalOwner: local.originalOwner ?? null,
1475
+ };
1476
+ messages.push("shared to local agents");
1477
+ }
1478
+
1479
+ if (target === "hub" || target === "both") {
1480
+ const hub = await shareMemoryToHub(chunkId, { visibility, groupId, hubAddress, userToken });
1481
+ details.hub = {
1482
+ shared: true,
1483
+ memoryId: hub.memoryId,
1484
+ visibility: hub.visibility,
1485
+ groupId: hub.groupId,
1486
+ };
1487
+ messages.push(`shared to Hub (${hub.visibility})`);
1488
+ }
1489
+
1490
+ return {
1491
+ content: [{ type: "text", text: `Memory "${chunk.summary || chunk.id}" ${messages.join(" and ")}.` }],
1492
+ details,
1493
+ };
1494
+ }),
1495
+ },
1496
+ { name: "memory_share" },
1497
+ );
1498
+
1499
+ api.registerTool(
1500
+ {
1501
+ name: "memory_unshare",
1502
+ label: "Unshare Memory",
1503
+ description:
1504
+ "Remove sharing from an existing memory. Use target='agents' to stop local multi-agent sharing, target='hub' to remove it from Hub, or target='all' (default) to remove both. " +
1505
+ "privateOwner is only needed for older public memories that were never tracked with an original owner.",
1506
+ parameters: Type.Object({
1507
+ chunkId: Type.String({ description: "Existing local memory chunk ID to unshare" }),
1508
+ target: Type.Optional(Type.String({ description: "Unshare target: 'agents', 'hub', or 'all' (default)" })),
1509
+ privateOwner: Type.Optional(Type.String({ description: "Optional owner to restore when converting a public memory back to private and no original owner was tracked" })),
1510
+ hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override" })),
1511
+ userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override" })),
1512
+ }),
1513
+ execute: trackTool("memory_unshare", async (_toolCallId: any, params: any) => {
1514
+ const {
1515
+ chunkId,
1516
+ target: rawTarget,
1517
+ privateOwner,
1518
+ hubAddress,
1519
+ userToken,
1520
+ } = params as {
1521
+ chunkId: string;
1522
+ target?: string;
1523
+ privateOwner?: string;
1524
+ hubAddress?: string;
1525
+ userToken?: string;
1526
+ };
1527
+
1528
+ const chunk = store.getChunk(chunkId);
1529
+ if (!chunk) {
1530
+ return { content: [{ type: "text", text: `Memory not found: ${chunkId}` }], details: { error: "not_found", chunkId } };
1531
+ }
1532
+
1533
+ const target = resolveMemoryUnshareTarget(rawTarget);
1534
+ const details: Record<string, unknown> = { chunkId, target };
1535
+ const messages: string[] = [];
1536
+
1537
+ if (target === "agents" || target === "all") {
1538
+ const local = store.unmarkMemorySharedLocally(chunkId, privateOwner);
1539
+ if (!local.ok) {
1540
+ return {
1541
+ content: [{
1542
+ type: "text",
1543
+ text: local.reason === "original_owner_missing"
1544
+ ? `Cannot restore memory "${chunk.summary || chunk.id}" to a private owner automatically. Pass privateOwner to unshare it locally.`
1545
+ : `Failed to stop local sharing for memory ${chunkId}.`,
1546
+ }],
1547
+ details: { error: local.reason ?? "local_unshare_failed", chunkId, target },
1548
+ };
1549
+ }
1550
+ details.local = {
1551
+ shared: false,
1552
+ owner: local.owner,
1553
+ };
1554
+ messages.push("removed from local agent sharing");
1555
+ }
1556
+
1557
+ if (target === "hub" || target === "all") {
1558
+ try {
1559
+ await unshareMemoryFromHub(chunkId, { hubAddress, userToken });
1560
+ details.hub = { shared: false };
1561
+ messages.push("removed from Hub");
1562
+ } catch (err) {
1563
+ const msg = err instanceof Error ? err.message : String(err);
1564
+ if (target === "all" && msg.includes("hub client connection is not configured")) {
1565
+ details.hub = { shared: false, skipped: true, reason: "hub_not_configured" };
1566
+ } else {
1567
+ throw err;
1568
+ }
1569
+ }
1570
+ }
1571
+
1572
+ return {
1573
+ content: [{ type: "text", text: `Memory "${chunk.summary || chunk.id}" ${messages.join(" and ")}.` }],
1574
+ details,
1575
+ };
1576
+ }),
1577
+ },
1578
+ { name: "memory_unshare" },
1579
+ );
1580
+
1079
1581
  // ─── Tool: skill_search ───
1080
1582
 
1081
1583
  api.registerTool(
@@ -1083,16 +1585,16 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1083
1585
  name: "skill_search",
1084
1586
  label: "Skill Search",
1085
1587
  description:
1086
- "Search available skills by natural language. Searches local skills by default, or local + Hub skills when scope=group/all. " +
1087
- "Use when you need a capability or guide and don't have a matching skill at hand.",
1588
+ "Search available skills by natural language. Use scope='mix' (default) for this agent plus local shared skills, 'self' for this agent only, 'public' for local shared skills only, or 'group'/'all' to include Hub skills as well. " +
1589
+ "Use this when you need a capability or guide and don't have a matching skill at hand.",
1088
1590
  parameters: Type.Object({
1089
1591
  query: Type.String({ description: "Natural language description of the needed skill" }),
1090
- scope: Type.Optional(Type.String({ description: "Search scope: 'mix'/'self'/'public' for local search, or 'group'/'all' for local + Hub search" })),
1592
+ scope: Type.Optional(Type.String({ description: "Search scope: 'mix' (default), 'self', 'public', 'group', or 'all'." })),
1091
1593
  }),
1092
1594
  execute: trackTool("skill_search", async (_toolCallId: any, params: any, context?: any) => {
1093
1595
  const { query: skillQuery, scope: rawScope } = params as { query: string; scope?: string };
1094
1596
  const scope = (rawScope === "self" || rawScope === "public") ? rawScope : "mix";
1095
- const currentOwner = `agent:${currentAgentId}`;
1597
+ const currentOwner = getCurrentOwner();
1096
1598
 
1097
1599
  if (rawScope === "group" || rawScope === "all") {
1098
1600
  const [localHits, hub] = await Promise.all([
@@ -1108,7 +1610,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1108
1610
  }
1109
1611
 
1110
1612
  const localText = localHits.length > 0
1111
- ? localHits.map((h, i) => `${i + 1}. [${h.name}] ${h.description.slice(0, 150)}${h.visibility === "public" ? " (public)" : ""}`).join("\n")
1613
+ ? localHits.map((h, i) => `${i + 1}. [${h.name}] ${h.description.slice(0, 150)}${h.visibility === "public" ? " (shared to local agents)" : ""}`).join("\n")
1112
1614
  : "(none)";
1113
1615
  const hubText = hub.hits.length > 0
1114
1616
  ? hub.hits.map((h, i) => `${i + 1}. [${h.name}] ${h.description.slice(0, 150)} (${h.visibility}${h.groupName ? `:${h.groupName}` : ""}, owner=${h.ownerName})`).join("\n")
@@ -1130,7 +1632,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1130
1632
  }
1131
1633
 
1132
1634
  const text = hits.map((h, i) =>
1133
- `${i + 1}. [${h.name}] ${h.description}${h.visibility === "public" ? " (public)" : ""}`,
1635
+ `${i + 1}. [${h.name}] ${h.description}${h.visibility === "public" ? " (shared to local agents)" : ""}`,
1134
1636
  ).join("\n");
1135
1637
 
1136
1638
  return {
@@ -1148,31 +1650,54 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1148
1650
  {
1149
1651
  name: "skill_publish",
1150
1652
  label: "Publish Skill",
1151
- description: "Make a skill public so other agents can discover and install it via skill_search.",
1653
+ description:
1654
+ "Share a skill with local agents or publish it to the Hub. " +
1655
+ "Use target='agents' for local sharing, or target='hub' with visibility='public'/'group' for Hub publishing. " +
1656
+ "The old scope parameter is still accepted for backward compatibility.",
1152
1657
  parameters: Type.Object({
1153
1658
  skillId: Type.String({ description: "The skill ID to publish" }),
1154
- scope: Type.Optional(Type.String({ description: "Publish scope: omit for local public, or use 'public' / 'group' to publish to Hub" })),
1659
+ target: Type.Optional(Type.String({ description: "Publish target: 'agents' (default) or 'hub'." })),
1660
+ visibility: Type.Optional(Type.String({ description: "Hub visibility when target='hub': 'public' (default) or 'group'." })),
1661
+ scope: Type.Optional(Type.String({ description: "Deprecated alias: omit for local agents, or use 'public' / 'group' to publish to Hub." })),
1155
1662
  groupId: Type.Optional(Type.String({ description: "Optional group ID when scope='group'" })),
1156
1663
  hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override for tests or manual routing" })),
1157
1664
  userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override for tests" })),
1158
1665
  }),
1159
1666
  execute: trackTool("skill_publish", async (_toolCallId: any, params: any) => {
1160
- const { skillId: pubSkillId, scope, groupId, hubAddress, userToken } = params as { skillId: string; scope?: string; groupId?: string; hubAddress?: string; userToken?: string };
1667
+ const {
1668
+ skillId: pubSkillId,
1669
+ target: rawTarget,
1670
+ visibility: rawVisibility,
1671
+ scope,
1672
+ groupId,
1673
+ hubAddress,
1674
+ userToken,
1675
+ } = params as {
1676
+ skillId: string;
1677
+ target?: string;
1678
+ visibility?: string;
1679
+ scope?: string;
1680
+ groupId?: string;
1681
+ hubAddress?: string;
1682
+ userToken?: string;
1683
+ };
1161
1684
  const skill = store.getSkill(pubSkillId);
1162
1685
  if (!skill) {
1163
1686
  return { content: [{ type: "text", text: `Skill not found: ${pubSkillId}` }] };
1164
1687
  }
1165
- if (scope === "public" || scope === "group") {
1166
- const published = await publishSkillBundleToHub(store, ctx, { skillId: pubSkillId, visibility: scope, groupId, hubAddress, userToken });
1688
+ const target = resolveSkillPublishTarget(rawTarget, scope);
1689
+ const visibility = resolveSkillHubVisibility(rawVisibility, scope);
1690
+ if (target === "hub") {
1691
+ const published = await publishSkillBundleToHub(store, ctx, { skillId: pubSkillId, visibility, groupId, hubAddress, userToken });
1167
1692
  return {
1168
- content: [{ type: "text", text: `Skill "${skill.name}" published to hub (${published.visibility}).` }],
1169
- details: { skillId: pubSkillId, name: skill.name, publishedToHub: true, hubSkillId: published.skillId, visibility: published.visibility },
1693
+ content: [{ type: "text", text: `Skill "${skill.name}" shared to Hub (${published.visibility}).` }],
1694
+ details: { skillId: pubSkillId, name: skill.name, target, publishedToHub: true, hubSkillId: published.skillId, visibility: published.visibility },
1170
1695
  };
1171
1696
  }
1172
1697
  store.setSkillVisibility(pubSkillId, "public");
1173
1698
  return {
1174
- content: [{ type: "text", text: `Skill "${skill.name}" is now public.` }],
1175
- details: { skillId: pubSkillId, name: skill.name, visibility: "public", publishedToHub: false },
1699
+ content: [{ type: "text", text: `Skill "${skill.name}" is now shared with local agents.` }],
1700
+ details: { skillId: pubSkillId, name: skill.name, target, visibility: "public", publishedToHub: false },
1176
1701
  };
1177
1702
  }),
1178
1703
  },
@@ -1185,20 +1710,46 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1185
1710
  {
1186
1711
  name: "skill_unpublish",
1187
1712
  label: "Unpublish Skill",
1188
- description: "Make a skill private. Other agents will no longer be able to discover it.",
1713
+ description:
1714
+ "Stop sharing a skill with local agents, remove it from the Hub, or do both. " +
1715
+ "Use target='agents' (default), 'hub', or 'all'.",
1189
1716
  parameters: Type.Object({
1190
1717
  skillId: Type.String({ description: "The skill ID to unpublish" }),
1718
+ target: Type.Optional(Type.String({ description: "Unpublish target: 'agents' (default), 'hub', or 'all'." })),
1719
+ hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override for tests or manual routing" })),
1720
+ userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override for tests" })),
1191
1721
  }),
1192
1722
  execute: trackTool("skill_unpublish", async (_toolCallId: any, params: any) => {
1193
- const { skillId: unpubSkillId } = params as { skillId: string };
1723
+ const { skillId: unpubSkillId, target, hubAddress, userToken } = params as { skillId: string; target?: string; hubAddress?: string; userToken?: string };
1194
1724
  const skill = store.getSkill(unpubSkillId);
1195
1725
  if (!skill) {
1196
1726
  return { content: [{ type: "text", text: `Skill not found: ${unpubSkillId}` }] };
1197
1727
  }
1198
- store.setSkillVisibility(unpubSkillId, "private");
1728
+ const resolvedTarget = resolveSkillUnpublishTarget(target);
1729
+ const messages: string[] = [];
1730
+ const details: Record<string, unknown> = { skillId: unpubSkillId, name: skill.name, target: resolvedTarget };
1731
+ if (resolvedTarget === "hub" || resolvedTarget === "all") {
1732
+ try {
1733
+ await unpublishSkillBundleFromHub(store, ctx, { skillId: unpubSkillId, hubAddress, userToken });
1734
+ details.hub = { unpublished: true };
1735
+ messages.push("removed from Hub sharing");
1736
+ } catch (err) {
1737
+ const msg = err instanceof Error ? err.message : String(err);
1738
+ if (resolvedTarget === "all" && msg.includes("hub client connection is not configured")) {
1739
+ details.hub = { unpublished: false, skipped: true, reason: "hub_not_configured" };
1740
+ } else {
1741
+ throw err;
1742
+ }
1743
+ }
1744
+ }
1745
+ if (resolvedTarget === "agents" || resolvedTarget === "all") {
1746
+ store.setSkillVisibility(unpubSkillId, "private");
1747
+ details.local = { visibility: "private" };
1748
+ messages.push("limited to this agent");
1749
+ }
1199
1750
  return {
1200
- content: [{ type: "text", text: `Skill "${skill.name}" is now private.` }],
1201
- details: { skillId: unpubSkillId, name: skill.name, visibility: "private" },
1751
+ content: [{ type: "text", text: `Skill "${skill.name}" ${messages.join(" and ")}.` }],
1752
+ details,
1202
1753
  };
1203
1754
  }),
1204
1755
  },
@@ -1231,6 +1782,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1231
1782
  // ─── Auto-recall: inject relevant memories before agent starts ───
1232
1783
 
1233
1784
  api.on("before_agent_start", async (event: { prompt?: string; messages?: unknown[] }, hookCtx?: { agentId?: string; sessionKey?: string }) => {
1785
+ if (!allowPromptInjection) return {};
1234
1786
  if (!event.prompt || event.prompt.length < 3) return;
1235
1787
 
1236
1788
  const recallAgentId = hookCtx?.agentId ?? "main";
@@ -1272,11 +1824,73 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1272
1824
  ctx.log.debug(`auto-recall: query="${query.slice(0, 80)}"`);
1273
1825
 
1274
1826
  const result = await engine.search({ query, maxResults: 10, minScore: 0.45, ownerFilter: recallOwnerFilter });
1827
+
1828
+ // Hub fallback helper: search team shared memories when local search has no relevant results
1829
+ const hubFallback = async (): Promise<SearchHit[]> => {
1830
+ if (!ctx.config?.sharing?.enabled) return [];
1831
+ try {
1832
+ const hubResult = await hubSearchMemories(store, ctx, { query, maxResults: 10, scope: "all" });
1833
+ if (hubResult.hits.length === 0) return [];
1834
+ ctx.log.debug(`auto-recall: hub fallback returned ${hubResult.hits.length} hit(s)`);
1835
+ return hubResult.hits.map((h) => ({
1836
+ summary: h.summary,
1837
+ original_excerpt: h.excerpt || h.summary,
1838
+ ref: { sessionKey: "", chunkId: h.remoteHitId, turnId: "", seq: 0 },
1839
+ score: 0.9,
1840
+ taskId: null,
1841
+ skillId: null,
1842
+ origin: "hub-remote" as const,
1843
+ source: { ts: h.source.ts, role: h.source.role, sessionKey: "" },
1844
+ }));
1845
+ } catch (err) {
1846
+ ctx.log.debug(`auto-recall: hub fallback failed (${err})`);
1847
+ return [];
1848
+ }
1849
+ };
1850
+
1851
+ if (result.hits.length === 0) {
1852
+ // Local found nothing — try hub before giving up
1853
+ const hubHits = await hubFallback();
1854
+ if (hubHits.length > 0) {
1855
+ result.hits.push(...hubHits);
1856
+ ctx.log.debug(`auto-recall: local empty, using ${hubHits.length} hub hit(s)`);
1857
+ }
1858
+ }
1275
1859
  if (result.hits.length === 0) {
1276
- ctx.log.debug("auto-recall: no candidates found");
1860
+ ctx.log.debug("auto-recall: no memory candidates found");
1277
1861
  const dur = performance.now() - recallT0;
1278
1862
  store.recordToolCall("memory_search", dur, true);
1279
1863
  store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({ candidates: [], filtered: [] }), dur, true);
1864
+
1865
+ // Even without memory hits, try skill recall
1866
+ const skillAutoRecallEarly = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall;
1867
+ if (skillAutoRecallEarly) {
1868
+ try {
1869
+ const skillLimit = ctx.config.skillEvolution?.autoRecallSkillLimit ?? DEFAULTS.skillAutoRecallLimit;
1870
+ const skillHits = await engine.searchSkills(query, "mix" as any, getCurrentOwner());
1871
+ const topSkills = skillHits.slice(0, skillLimit);
1872
+ if (topSkills.length > 0) {
1873
+ const skillLines = topSkills.map((sc, i) => {
1874
+ const manifest = skillInstaller.getCompanionManifest(sc.skillId);
1875
+ let badge = "";
1876
+ if (manifest?.installed) badge = " [installed]";
1877
+ else if (manifest?.installMode === "install_recommended") badge = " [has scripts, install recommended]";
1878
+ else if (manifest?.hasCompanionFiles) badge = " [has companion files]";
1879
+ return `${i + 1}. **${sc.name}**${badge} — ${sc.description.slice(0, 200)}\n → call \`skill_get(skillId="${sc.skillId}")\` for the full guide`;
1880
+ });
1881
+ const skillContext = "## Relevant skills from past experience\n\n" +
1882
+ "No direct memory matches were found, but these skills from past tasks may help:\n\n" +
1883
+ skillLines.join("\n\n") +
1884
+ "\n\nYou SHOULD call `skill_get` to retrieve the full guide before attempting the task.";
1885
+ ctx.log.info(`auto-recall-skill (no-memory path): injecting ${topSkills.length} skill(s)`);
1886
+ try { store.recordApiLog("skill_search", { type: "auto_recall_skill", query }, JSON.stringify(topSkills), dur, true); } catch { /* best-effort */ }
1887
+ return { prependContext: skillContext };
1888
+ }
1889
+ } catch (err) {
1890
+ ctx.log.debug(`auto-recall-skill (no-memory path): failed: ${err}`);
1891
+ }
1892
+ }
1893
+
1280
1894
  if (query.length > 50) {
1281
1895
  const noRecallHint =
1282
1896
  "## Memory system — ACTION REQUIRED\n\n" +
@@ -1305,22 +1919,36 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1305
1919
  const indexSet = new Set(filterResult.relevant);
1306
1920
  filteredHits = result.hits.filter((_, i) => indexSet.has(i + 1));
1307
1921
  } else {
1308
- ctx.log.debug("auto-recall: LLM filter returned no relevant hits");
1309
- const dur = performance.now() - recallT0;
1310
- store.recordToolCall("memory_search", dur, true);
1311
- store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({
1312
- candidates: result.hits.map(h => ({ score: h.score, role: h.source.role, summary: h.summary, content: h.original_excerpt })),
1313
- filtered: []
1314
- }), dur, true);
1315
- if (query.length > 50) {
1316
- const noRecallHint =
1317
- "## Memory system ACTION REQUIRED\n\n" +
1318
- "Auto-recall found no relevant results for a long query. " +
1319
- "You MUST call `memory_search` now with a shortened query (2-5 key words) before answering. " +
1320
- "Do NOT skip this step. Do NOT answer without searching first.";
1321
- return { prependContext: noRecallHint };
1922
+ ctx.log.debug("auto-recall: LLM filter returned no relevant local hits, trying hub fallback");
1923
+ const hubHits = await hubFallback();
1924
+ if (hubHits.length > 0) {
1925
+ ctx.log.debug(`auto-recall: hub fallback provided ${hubHits.length} hit(s) after local filter yielded 0`);
1926
+ filteredHits = hubHits;
1927
+ } else {
1928
+ const dur = performance.now() - recallT0;
1929
+ store.recordToolCall("memory_search", dur, true);
1930
+ store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({
1931
+ candidates: result.hits.map(h => ({ score: h.score, role: h.source.role, summary: h.summary, content: h.original_excerpt, origin: h.origin || "local" })),
1932
+ filtered: []
1933
+ }), dur, true);
1934
+ if (query.length > 50) {
1935
+ const noRecallHint =
1936
+ "## Memory system — ACTION REQUIRED\n\n" +
1937
+ "Auto-recall found no relevant results for a long query. " +
1938
+ "You MUST call `memory_search` now with a shortened query (2-5 key words) before answering. " +
1939
+ "Do NOT skip this step. Do NOT answer without searching first.";
1940
+ return { prependContext: noRecallHint };
1941
+ }
1942
+ return;
1322
1943
  }
1323
- return;
1944
+ }
1945
+ }
1946
+
1947
+ if (!sufficient && filteredHits.length > 0 && ctx.config?.sharing?.enabled) {
1948
+ const hubSupp = await hubFallback();
1949
+ if (hubSupp.length > 0) {
1950
+ ctx.log.debug(`auto-recall: local insufficient, supplementing with ${hubSupp.length} hub hit(s)`);
1951
+ filteredHits.push(...hubSupp);
1324
1952
  }
1325
1953
  }
1326
1954
 
@@ -1330,7 +1958,8 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1330
1958
 
1331
1959
  const lines = filteredHits.map((h, i) => {
1332
1960
  const excerpt = h.original_excerpt;
1333
- const parts: string[] = [`${i + 1}. [${h.source.role}]`];
1961
+ const oTag = h.origin === "local-shared" ? " [本机共享]" : h.origin === "hub-memory" ? " [团队缓存]" : "";
1962
+ const parts: string[] = [`${i + 1}. [${h.source.role}]${oTag}`];
1334
1963
  if (excerpt) parts.push(` ${excerpt}`);
1335
1964
  parts.push(` chunkId="${h.ref.chunkId}"`);
1336
1965
  if (h.taskId) {
@@ -1365,17 +1994,86 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1365
1994
  lines.join("\n\n"),
1366
1995
  ];
1367
1996
  if (tipsText) contextParts.push(tipsText);
1997
+
1998
+ // ─── Skill auto-recall ───
1999
+ const skillAutoRecall = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall;
2000
+ const skillLimit = ctx.config.skillEvolution?.autoRecallSkillLimit ?? DEFAULTS.skillAutoRecallLimit;
2001
+ let skillSection = "";
2002
+
2003
+ if (skillAutoRecall) {
2004
+ try {
2005
+ const skillCandidateMap = new Map<string, { name: string; description: string; skillId: string; source: string }>();
2006
+
2007
+ // Source 1: direct skill search based on user query
2008
+ try {
2009
+ const directSkillHits = await engine.searchSkills(query, "mix" as any, getCurrentOwner());
2010
+ for (const sh of directSkillHits.slice(0, skillLimit + 2)) {
2011
+ if (!skillCandidateMap.has(sh.skillId)) {
2012
+ skillCandidateMap.set(sh.skillId, { name: sh.name, description: sh.description, skillId: sh.skillId, source: "query" });
2013
+ }
2014
+ }
2015
+ } catch (err) {
2016
+ ctx.log.debug(`auto-recall-skill: direct search failed: ${err}`);
2017
+ }
2018
+
2019
+ // Source 2: skills linked to tasks from memory hits
2020
+ const taskIds = new Set<string>();
2021
+ for (const h of filteredHits) {
2022
+ if (h.taskId) {
2023
+ const t = store.getTask(h.taskId);
2024
+ if (t && t.status !== "skipped") taskIds.add(h.taskId);
2025
+ }
2026
+ }
2027
+ for (const tid of taskIds) {
2028
+ const linked = store.getSkillsByTask(tid);
2029
+ for (const rs of linked) {
2030
+ if (!skillCandidateMap.has(rs.skill.id)) {
2031
+ skillCandidateMap.set(rs.skill.id, { name: rs.skill.name, description: rs.skill.description, skillId: rs.skill.id, source: `task:${tid}` });
2032
+ }
2033
+ }
2034
+ }
2035
+
2036
+ const skillCandidates = [...skillCandidateMap.values()].slice(0, skillLimit);
2037
+
2038
+ if (skillCandidates.length > 0) {
2039
+ const skillLines = skillCandidates.map((sc, i) => {
2040
+ const manifest = skillInstaller.getCompanionManifest(sc.skillId);
2041
+ let badge = "";
2042
+ if (manifest?.installed) badge = " [installed]";
2043
+ else if (manifest?.installMode === "install_recommended") badge = " [has scripts, install recommended]";
2044
+ else if (manifest?.hasCompanionFiles) badge = " [has companion files]";
2045
+ const action = `call \`skill_get(skillId="${sc.skillId}")\``;
2046
+ return `${i + 1}. **${sc.name}**${badge} — ${sc.description.slice(0, 200)}\n → ${action}`;
2047
+ });
2048
+ skillSection = "\n\n## Relevant skills from past experience\n\n" +
2049
+ "The following skills were distilled from similar previous tasks. " +
2050
+ "You SHOULD call `skill_get` to retrieve the full guide before attempting the task.\n\n" +
2051
+ skillLines.join("\n\n");
2052
+
2053
+ ctx.log.info(`auto-recall-skill: injecting ${skillCandidates.length} skill(s): ${skillCandidates.map(s => s.name).join(", ")}`);
2054
+ try {
2055
+ store.recordApiLog("skill_search", { type: "auto_recall_skill", query }, JSON.stringify(skillCandidates), performance.now() - recallT0, true);
2056
+ } catch { /* best-effort */ }
2057
+ } else {
2058
+ ctx.log.debug("auto-recall-skill: no matching skills found");
2059
+ }
2060
+ } catch (err) {
2061
+ ctx.log.debug(`auto-recall-skill: failed: ${err}`);
2062
+ }
2063
+ }
2064
+
2065
+ if (skillSection) contextParts.push(skillSection);
1368
2066
  const context = contextParts.join("\n");
1369
2067
 
1370
2068
  const recallDur = performance.now() - recallT0;
1371
2069
  store.recordToolCall("memory_search", recallDur, true);
1372
2070
  store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({
1373
- candidates: result.hits.map(h => ({ score: h.score, role: h.source.role, summary: h.summary, content: h.original_excerpt })),
1374
- filtered: filteredHits.map(h => ({ score: h.score, role: h.source.role, summary: h.summary, content: h.original_excerpt }))
2071
+ candidates: result.hits.map(h => ({ score: h.score, role: h.source.role, summary: h.summary, content: h.original_excerpt, origin: h.origin || "local" })),
2072
+ filtered: filteredHits.map(h => ({ score: h.score, role: h.source.role, summary: h.summary, content: h.original_excerpt, origin: h.origin || "local" }))
1375
2073
  }), recallDur, true);
1376
2074
  telemetry.trackAutoRecall(filteredHits.length, recallDur);
1377
2075
 
1378
- ctx.log.info(`auto-recall: returning prependContext (${context.length} chars), sufficient=${sufficient}`);
2076
+ ctx.log.info(`auto-recall: returning prependContext (${context.length} chars), sufficient=${sufficient}, skills=${skillSection ? "yes" : "no"}`);
1379
2077
 
1380
2078
  if (!sufficient) {
1381
2079
  const searchHint =
@@ -1602,6 +2300,8 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1602
2300
 
1603
2301
  // ─── Memory Viewer (web UI) ───
1604
2302
 
2303
+ const derivedHubPort = gatewayPort + 11;
2304
+
1605
2305
  const viewer = new ViewerServer({
1606
2306
  store,
1607
2307
  embedder,
@@ -1609,10 +2309,10 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1609
2309
  log: ctx.log,
1610
2310
  dataDir: stateDir,
1611
2311
  ctx,
2312
+ defaultHubPort: derivedHubPort,
1612
2313
  });
1613
-
1614
2314
  const hubServer = ctx.config.sharing?.enabled && ctx.config.sharing.role === "hub"
1615
- ? new HubServer({ store, log: ctx.log, config: ctx.config, dataDir: stateDir, embedder })
2315
+ ? new HubServer({ store, log: ctx.log, config: ctx.config, dataDir: stateDir, embedder, defaultHubPort: derivedHubPort })
1616
2316
  : null;
1617
2317
 
1618
2318
  // ─── Service lifecycle ───