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

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 (153) hide show
  1. package/README.md +39 -22
  2. package/dist/capture/index.d.ts +1 -1
  3. package/dist/capture/index.d.ts.map +1 -1
  4. package/dist/capture/index.js +27 -7
  5. package/dist/capture/index.js.map +1 -1
  6. package/dist/client/connector.d.ts +29 -0
  7. package/dist/client/connector.d.ts.map +1 -0
  8. package/dist/client/connector.js +218 -0
  9. package/dist/client/connector.js.map +1 -0
  10. package/dist/client/hub.d.ts +61 -0
  11. package/dist/client/hub.d.ts.map +1 -0
  12. package/dist/client/hub.js +170 -0
  13. package/dist/client/hub.js.map +1 -0
  14. package/dist/client/skill-sync.d.ts +36 -0
  15. package/dist/client/skill-sync.d.ts.map +1 -0
  16. package/dist/client/skill-sync.js +226 -0
  17. package/dist/client/skill-sync.js.map +1 -0
  18. package/dist/config.d.ts +2 -1
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +72 -3
  21. package/dist/config.js.map +1 -1
  22. package/dist/embedding/index.d.ts +4 -2
  23. package/dist/embedding/index.d.ts.map +1 -1
  24. package/dist/embedding/index.js +17 -1
  25. package/dist/embedding/index.js.map +1 -1
  26. package/dist/hub/auth.d.ts +19 -0
  27. package/dist/hub/auth.d.ts.map +1 -0
  28. package/dist/hub/auth.js +70 -0
  29. package/dist/hub/auth.js.map +1 -0
  30. package/dist/hub/server.d.ts +41 -0
  31. package/dist/hub/server.d.ts.map +1 -0
  32. package/dist/hub/server.js +767 -0
  33. package/dist/hub/server.js.map +1 -0
  34. package/dist/hub/user-manager.d.ts +31 -0
  35. package/dist/hub/user-manager.d.ts.map +1 -0
  36. package/dist/hub/user-manager.js +129 -0
  37. package/dist/hub/user-manager.js.map +1 -0
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +8 -4
  41. package/dist/index.js.map +1 -1
  42. package/dist/ingest/providers/index.d.ts +10 -2
  43. package/dist/ingest/providers/index.d.ts.map +1 -1
  44. package/dist/ingest/providers/index.js +209 -43
  45. package/dist/ingest/providers/index.js.map +1 -1
  46. package/dist/ingest/providers/openai.d.ts +1 -0
  47. package/dist/ingest/providers/openai.d.ts.map +1 -1
  48. package/dist/ingest/providers/openai.js +1 -0
  49. package/dist/ingest/providers/openai.js.map +1 -1
  50. package/dist/ingest/task-processor.js +1 -1
  51. package/dist/ingest/task-processor.js.map +1 -1
  52. package/dist/openclaw-api.d.ts +53 -0
  53. package/dist/openclaw-api.d.ts.map +1 -0
  54. package/dist/openclaw-api.js +189 -0
  55. package/dist/openclaw-api.js.map +1 -0
  56. package/dist/recall/engine.js +1 -1
  57. package/dist/recall/engine.js.map +1 -1
  58. package/dist/shared/llm-call.d.ts +4 -2
  59. package/dist/shared/llm-call.d.ts.map +1 -1
  60. package/dist/shared/llm-call.js +20 -81
  61. package/dist/shared/llm-call.js.map +1 -1
  62. package/dist/sharing/types.contract.d.ts +2 -0
  63. package/dist/sharing/types.contract.d.ts.map +1 -0
  64. package/dist/sharing/types.contract.js +3 -0
  65. package/dist/sharing/types.contract.js.map +1 -0
  66. package/dist/sharing/types.d.ts +80 -0
  67. package/dist/sharing/types.d.ts.map +1 -0
  68. package/dist/sharing/types.js +3 -0
  69. package/dist/sharing/types.js.map +1 -0
  70. package/dist/skill/evaluator.d.ts.map +1 -1
  71. package/dist/skill/evaluator.js +2 -2
  72. package/dist/skill/evaluator.js.map +1 -1
  73. package/dist/skill/evolver.d.ts +0 -2
  74. package/dist/skill/evolver.d.ts.map +1 -1
  75. package/dist/skill/evolver.js +0 -3
  76. package/dist/skill/evolver.js.map +1 -1
  77. package/dist/skill/generator.d.ts.map +1 -1
  78. package/dist/skill/generator.js +4 -4
  79. package/dist/skill/generator.js.map +1 -1
  80. package/dist/skill/upgrader.js +1 -1
  81. package/dist/skill/upgrader.js.map +1 -1
  82. package/dist/skill/validator.js +1 -1
  83. package/dist/skill/validator.js.map +1 -1
  84. package/dist/storage/ensure-binding.d.ts.map +1 -1
  85. package/dist/storage/ensure-binding.js +3 -1
  86. package/dist/storage/ensure-binding.js.map +1 -1
  87. package/dist/storage/sqlite.d.ts +329 -1
  88. package/dist/storage/sqlite.d.ts.map +1 -1
  89. package/dist/storage/sqlite.js +909 -4
  90. package/dist/storage/sqlite.js.map +1 -1
  91. package/dist/telemetry.d.ts +5 -12
  92. package/dist/telemetry.d.ts.map +1 -1
  93. package/dist/telemetry.js +38 -135
  94. package/dist/telemetry.js.map +1 -1
  95. package/dist/tools/index.d.ts +1 -0
  96. package/dist/tools/index.d.ts.map +1 -1
  97. package/dist/tools/index.js +3 -1
  98. package/dist/tools/index.js.map +1 -1
  99. package/dist/tools/memory-search.d.ts +5 -2
  100. package/dist/tools/memory-search.d.ts.map +1 -1
  101. package/dist/tools/memory-search.js +50 -7
  102. package/dist/tools/memory-search.js.map +1 -1
  103. package/dist/tools/network-memory-detail.d.ts +4 -0
  104. package/dist/tools/network-memory-detail.d.ts.map +1 -0
  105. package/dist/tools/network-memory-detail.js +34 -0
  106. package/dist/tools/network-memory-detail.js.map +1 -0
  107. package/dist/types.d.ts +49 -2
  108. package/dist/types.d.ts.map +1 -1
  109. package/dist/types.js.map +1 -1
  110. package/dist/viewer/html.d.ts.map +1 -1
  111. package/dist/viewer/html.js +3965 -459
  112. package/dist/viewer/html.js.map +1 -1
  113. package/dist/viewer/server.d.ts +51 -0
  114. package/dist/viewer/server.d.ts.map +1 -1
  115. package/dist/viewer/server.js +1564 -23
  116. package/dist/viewer/server.js.map +1 -1
  117. package/index.ts +769 -67
  118. package/openclaw.plugin.json +2 -1
  119. package/package.json +4 -3
  120. package/scripts/postinstall.cjs +283 -46
  121. package/skill/memos-memory-guide/SKILL.md +82 -20
  122. package/src/capture/index.ts +27 -7
  123. package/src/client/connector.ts +212 -0
  124. package/src/client/hub.ts +207 -0
  125. package/src/client/skill-sync.ts +216 -0
  126. package/src/config.ts +94 -3
  127. package/src/embedding/index.ts +21 -1
  128. package/src/hub/auth.ts +78 -0
  129. package/src/hub/server.ts +754 -0
  130. package/src/hub/user-manager.ts +143 -0
  131. package/src/index.ts +13 -5
  132. package/src/ingest/providers/index.ts +246 -46
  133. package/src/ingest/providers/openai.ts +1 -1
  134. package/src/ingest/task-processor.ts +1 -1
  135. package/src/openclaw-api.ts +287 -0
  136. package/src/recall/engine.ts +1 -1
  137. package/src/shared/llm-call.ts +23 -95
  138. package/src/sharing/types.contract.ts +40 -0
  139. package/src/sharing/types.ts +102 -0
  140. package/src/skill/evaluator.ts +3 -2
  141. package/src/skill/evolver.ts +0 -5
  142. package/src/skill/generator.ts +6 -4
  143. package/src/skill/upgrader.ts +1 -1
  144. package/src/skill/validator.ts +1 -1
  145. package/src/storage/ensure-binding.ts +3 -1
  146. package/src/storage/sqlite.ts +1159 -4
  147. package/src/telemetry.ts +39 -152
  148. package/src/tools/index.ts +1 -0
  149. package/src/tools/memory-search.ts +58 -8
  150. package/src/tools/network-memory-detail.ts +34 -0
  151. package/src/types.ts +44 -2
  152. package/src/viewer/html.ts +3965 -459
  153. package/src/viewer/server.ts +1452 -25
package/index.ts CHANGED
@@ -9,8 +9,8 @@ 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";
13
12
  import { buildContext } from "./src/config";
13
+ import type { HostModelsConfig } from "./src/openclaw-api";
14
14
  import { ensureSqliteBinding } from "./src/storage/ensure-binding";
15
15
  import { SqliteStore } from "./src/storage/sqlite";
16
16
  import { Embedder } from "./src/embedding";
@@ -19,6 +19,10 @@ import { RecallEngine } from "./src/recall/engine";
19
19
  import { captureMessages, stripInboundMetadata } from "./src/capture";
20
20
  import { DEFAULTS } from "./src/types";
21
21
  import { ViewerServer } from "./src/viewer/server";
22
+ import { HubServer } from "./src/hub/server";
23
+ import { hubGetMemoryDetail, hubRequestJson, hubSearchMemories, hubSearchSkills, resolveHubClient } from "./src/client/hub";
24
+ import { getHubStatus, connectToHub } from "./src/client/connector";
25
+ import { fetchHubSkillBundle, publishSkillBundleToHub, restoreSkillBundleFromHub, unpublishSkillBundleFromHub } from "./src/client/skill-sync";
22
26
  import { SkillEvolver } from "./src/skill/evolver";
23
27
  import { SkillInstaller } from "./src/skill/installer";
24
28
  import { Summarizer } from "./src/ingest/providers";
@@ -78,20 +82,13 @@ const memosLocalPlugin = {
78
82
 
79
83
  register(api: OpenClawPluginApi) {
80
84
  // ─── Ensure better-sqlite3 native module is available ───
81
- const pluginDir = path.dirname(fileURLToPath(import.meta.url));
82
-
83
- function normalizeFsPath(p: string): string {
84
- return path.resolve(p).replace(/\\/g, "/").toLowerCase();
85
- }
86
-
85
+ const pluginDir = path.dirname(new URL(import.meta.url).pathname);
87
86
  let sqliteReady = false;
88
87
 
89
88
  function trySqliteLoad(): boolean {
90
89
  try {
91
90
  const resolved = require.resolve("better-sqlite3", { paths: [pluginDir] });
92
- const resolvedNorm = normalizeFsPath(resolved);
93
- const pluginNorm = normalizeFsPath(pluginDir);
94
- if (!resolvedNorm.startsWith(pluginNorm + "/") && resolvedNorm !== pluginNorm) {
91
+ if (!resolved.startsWith(pluginDir)) {
95
92
  api.logger.warn(`memos-local: better-sqlite3 resolved outside plugin dir: ${resolved}`);
96
93
  return false;
97
94
  }
@@ -173,19 +170,37 @@ const memosLocalPlugin = {
173
170
  }
174
171
  }
175
172
 
176
- const pluginCfg = (api.pluginConfig ?? {}) as Record<string, unknown>;
173
+ let pluginCfg = (api.pluginConfig ?? {}) as Record<string, unknown>;
177
174
  const stateDir = api.resolvePath("~/.openclaw");
175
+
176
+ // Fallback: read config from file if not provided by OpenClaw
177
+ const configPath = path.join(stateDir, "state", "memos-local", "config.json");
178
+ if (Object.keys(pluginCfg).length === 0 && fs.existsSync(configPath)) {
179
+ try {
180
+ const fileConfig = JSON.parse(fs.readFileSync(configPath, "utf-8"));
181
+ pluginCfg = fileConfig;
182
+ api.logger.info(`memos-local: loaded config from ${configPath}`);
183
+ } catch (e) {
184
+ api.logger.warn(`memos-local: failed to load config from ${configPath}: ${e}`);
185
+ }
186
+ }
187
+
188
+ // Extract host model providers so OpenClawAPIClient can proxy completion/embedding
189
+ const hostModels: HostModelsConfig | undefined = api.config?.models?.providers
190
+ ? { providers: api.config.models.providers as Record<string, import("./src/openclaw-api").HostModelProvider> }
191
+ : undefined;
192
+
178
193
  const ctx = buildContext(stateDir, process.cwd(), pluginCfg as any, {
179
194
  debug: (msg: string) => api.logger.info(`[debug] ${msg}`),
180
195
  info: (msg: string) => api.logger.info(msg),
181
196
  warn: (msg: string) => api.logger.warn(msg),
182
197
  error: (msg: string) => api.logger.warn(`[error] ${msg}`),
183
- });
198
+ }, hostModels);
184
199
 
185
200
  ensureSqliteBinding(ctx.log);
186
201
 
187
202
  const store = new SqliteStore(ctx.config.storage!.dbPath!, ctx.log);
188
- const embedder = new Embedder(ctx.config.embedding, ctx.log);
203
+ const embedder = new Embedder(ctx.config.embedding, ctx.log, ctx.openclawAPI);
189
204
  const worker = new IngestWorker(store, embedder, ctx);
190
205
  const engine = new RecallEngine(store, embedder, ctx);
191
206
  const evidenceTag = ctx.config.capture?.evidenceWrapperTag ?? DEFAULTS.evidenceWrapperTag;
@@ -193,7 +208,6 @@ const memosLocalPlugin = {
193
208
  const workspaceDir = api.resolvePath("~/.openclaw/workspace");
194
209
  const skillCtx = { ...ctx, workspaceDir };
195
210
  const skillEvolver = new SkillEvolver(store, engine, skillCtx);
196
- skillEvolver.onSkillEvolved = (name, type) => telemetry.trackSkillEvolved(name, type);
197
211
  const skillInstaller = new SkillInstaller(store, skillCtx);
198
212
 
199
213
  let pluginVersion = "0.0.0";
@@ -250,7 +264,7 @@ const memosLocalPlugin = {
250
264
  });
251
265
  });
252
266
 
253
- const summarizer = new Summarizer(ctx.config.summarizer, ctx.log);
267
+ const summarizer = new Summarizer(ctx.config.summarizer, ctx.log, ctx.openclawAPI);
254
268
 
255
269
  api.logger.info(`memos-local: initialized (db: ${ctx.config.storage!.dbPath})`);
256
270
 
@@ -258,18 +272,6 @@ const memosLocalPlugin = {
258
272
  // Falls back to "main" when no hook has fired yet (single-agent setups).
259
273
  let currentAgentId = "main";
260
274
 
261
- // ─── Check allowPromptInjection policy ───
262
- // When allowPromptInjection=false, the prompt mutation fields (such as prependContext) in the hook return value
263
- // will be stripped by the framework. Skip auto-recall to avoid unnecessary LLM/embedding calls.
264
- const pluginEntry = (api.config as any)?.plugins?.entries?.[api.id];
265
- const allowPromptInjection = pluginEntry?.hooks?.allowPromptInjection !== false;
266
- if (!allowPromptInjection) {
267
- api.logger.info("memos-local: allowPromptInjection=false, auto-recall disabled");
268
- }
269
- else {
270
- api.logger.info("memos-local: allowPromptInjection=true, auto-recall enabled");
271
- }
272
-
273
275
  const trackTool = (toolName: string, fn: (...args: any[]) => Promise<any>) =>
274
276
  async (...args: any[]) => {
275
277
  const t0 = performance.now();
@@ -281,7 +283,6 @@ const memosLocalPlugin = {
281
283
  return result;
282
284
  } catch (e) {
283
285
  ok = false;
284
- telemetry.trackError(toolName, (e as Error)?.name ?? "unknown");
285
286
  throw e;
286
287
  } finally {
287
288
  const dur = performance.now() - t0;
@@ -303,6 +304,89 @@ const memosLocalPlugin = {
303
304
  }
304
305
  };
305
306
 
307
+ const getCurrentOwner = () => `agent:${currentAgentId}`;
308
+ const resolveMemorySearchScope = (scope?: string): "local" | "group" | "all" =>
309
+ scope === "group" || scope === "all" ? scope : "local";
310
+ const resolveMemoryShareTarget = (target?: string): "agents" | "hub" | "both" =>
311
+ target === "hub" || target === "both" ? target : "agents";
312
+ const resolveMemoryUnshareTarget = (target?: string): "agents" | "hub" | "all" =>
313
+ target === "agents" || target === "hub" ? target : "all";
314
+ const resolveSkillPublishTarget = (target?: string, scope?: string): "agents" | "hub" => {
315
+ if (target === "hub") return "hub";
316
+ if (target === "agents") return "agents";
317
+ return scope === "public" || scope === "group" ? "hub" : "agents";
318
+ };
319
+ const resolveSkillHubVisibility = (visibility?: string, scope?: string): "public" | "group" =>
320
+ visibility === "group" || scope === "group" ? "group" : "public";
321
+ const resolveSkillUnpublishTarget = (target?: string): "agents" | "hub" | "all" =>
322
+ target === "hub" || target === "all" ? target : "agents";
323
+
324
+ const shareMemoryToHub = async (
325
+ chunkId: string,
326
+ input?: { visibility?: "public" | "group"; groupId?: string; hubAddress?: string; userToken?: string },
327
+ ): Promise<{ memoryId: string; visibility: "public" | "group"; groupId: string | null }> => {
328
+ const chunk = store.getChunk(chunkId);
329
+ if (!chunk) {
330
+ throw new Error(`Memory not found: ${chunkId}`);
331
+ }
332
+
333
+ const visibility = input?.visibility === "group" ? "group" : "public";
334
+ const groupId = visibility === "group" ? (input?.groupId ?? null) : null;
335
+ const hubClient = await resolveHubClient(store, ctx, { hubAddress: input?.hubAddress, userToken: input?.userToken });
336
+ const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/share", {
337
+ method: "POST",
338
+ body: JSON.stringify({
339
+ memory: {
340
+ sourceChunkId: chunk.id,
341
+ role: chunk.role,
342
+ content: chunk.content,
343
+ summary: chunk.summary,
344
+ kind: chunk.kind,
345
+ groupId,
346
+ visibility,
347
+ },
348
+ }),
349
+ }) as { memoryId?: string; visibility?: "public" | "group" };
350
+
351
+ const now = Date.now();
352
+ const existing = store.getHubMemoryBySource(hubClient.userId, chunk.id);
353
+ store.upsertHubMemory({
354
+ id: response?.memoryId ?? existing?.id ?? `${chunk.id}-hub`,
355
+ sourceChunkId: chunk.id,
356
+ sourceUserId: hubClient.userId,
357
+ role: chunk.role,
358
+ content: chunk.content,
359
+ summary: chunk.summary ?? "",
360
+ kind: chunk.kind,
361
+ groupId,
362
+ visibility,
363
+ createdAt: existing?.createdAt ?? now,
364
+ updatedAt: now,
365
+ });
366
+
367
+ return {
368
+ memoryId: response?.memoryId ?? existing?.id ?? `${chunk.id}-hub`,
369
+ visibility,
370
+ groupId,
371
+ };
372
+ };
373
+
374
+ const unshareMemoryFromHub = async (
375
+ chunkId: string,
376
+ input?: { hubAddress?: string; userToken?: string },
377
+ ): Promise<void> => {
378
+ const chunk = store.getChunk(chunkId);
379
+ if (!chunk) {
380
+ throw new Error(`Memory not found: ${chunkId}`);
381
+ }
382
+ const hubClient = await resolveHubClient(store, ctx, { hubAddress: input?.hubAddress, userToken: input?.userToken });
383
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/unshare", {
384
+ method: "POST",
385
+ body: JSON.stringify({ sourceChunkId: chunk.id }),
386
+ });
387
+ store.deleteHubMemoryBySource(hubClient.userId, chunk.id);
388
+ };
389
+
306
390
  // ─── Tool: memory_search ───
307
391
 
308
392
  api.registerTool(
@@ -311,20 +395,43 @@ const memosLocalPlugin = {
311
395
  label: "Memory Search",
312
396
  description:
313
397
  "Search long-term conversation memory for past conversations, user preferences, decisions, and experiences. " +
314
- "Relevant memories are automatically injected at the start of each turn, but call this tool when you need " +
315
- "to search with a different query or the auto-recalled context is insufficient. " +
316
- "Pass only a short natural-language query (2-5 key words).",
398
+ "Use scope='local' for this agent plus local shared memories, or scope='group'/'all' to include Hub-shared memories. " +
399
+ "Supports optional maxResults, minScore, and role filtering when you need tighter control.",
317
400
  parameters: Type.Object({
318
401
  query: Type.String({ description: "Short natural language search query (2-5 key words)" }),
402
+ scope: Type.Optional(Type.String({ description: "Search scope: 'local' (default), 'group', or 'all'. Use group/all to include Hub-shared memories." })),
403
+ maxResults: Type.Optional(Type.Number({ description: "Maximum results to return. Default 10, max 20." })),
404
+ minScore: Type.Optional(Type.Number({ description: "Minimum score threshold for local recall. Default 0.45, floor 0.35." })),
405
+ role: Type.Optional(Type.String({ description: "Optional local role filter: 'user', 'assistant', 'tool', or 'system'." })),
406
+ hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override for group/all search." })),
407
+ userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override for group/all search." })),
319
408
  }),
320
409
  execute: trackTool("memory_search", async (_toolCallId: any, params: any) => {
321
- const { query } = params as { query: string };
322
- const role = undefined;
323
- const minScore = undefined;
410
+ const {
411
+ query,
412
+ scope: rawScope,
413
+ maxResults,
414
+ minScore: rawMinScore,
415
+ role: rawRole,
416
+ hubAddress,
417
+ userToken,
418
+ } = params as {
419
+ query: string;
420
+ scope?: string;
421
+ maxResults?: number;
422
+ minScore?: number;
423
+ role?: string;
424
+ hubAddress?: string;
425
+ userToken?: string;
426
+ };
427
+ const role = rawRole === "user" || rawRole === "assistant" || rawRole === "tool" || rawRole === "system" ? rawRole : undefined;
428
+ const minScore = typeof rawMinScore === "number" ? Math.max(0.35, Math.min(1, rawMinScore)) : undefined;
429
+ const searchScope = resolveMemorySearchScope(rawScope);
430
+ const searchLimit = typeof maxResults === "number" ? Math.max(1, Math.min(20, Math.round(maxResults))) : 10;
324
431
 
325
432
  const agentId = currentAgentId;
326
- const ownerFilter = [`agent:${agentId}`, "public"];
327
- const effectiveMaxResults = 10;
433
+ const ownerFilter = [getCurrentOwner(), "public"];
434
+ const effectiveMaxResults = searchLimit;
328
435
  ctx.log.debug(`memory_search query="${query}" maxResults=${effectiveMaxResults} minScore=${minScore ?? 0.45} role=${role ?? "all"} owner=agent:${agentId}`);
329
436
  const result = await engine.search({ query, maxResults: effectiveMaxResults, minScore, role, ownerFilter });
330
437
  ctx.log.debug(`memory_search raw candidates: ${result.hits.length}`);
@@ -337,14 +444,13 @@ const memosLocalPlugin = {
337
444
  original_excerpt: (h.original_excerpt ?? "").slice(0, 200),
338
445
  }));
339
446
 
340
- if (result.hits.length === 0) {
447
+ if (result.hits.length === 0 && searchScope === "local") {
341
448
  return {
342
449
  content: [{ type: "text", text: result.meta.note ?? "No relevant memories found." }],
343
450
  details: { candidates: [], meta: result.meta },
344
451
  };
345
452
  }
346
453
 
347
- // LLM relevance + sufficiency filtering
348
454
  let filteredHits = result.hits;
349
455
  let sufficient = false;
350
456
 
@@ -362,14 +468,61 @@ const memosLocalPlugin = {
362
468
  const indexSet = new Set(filterResult.relevant);
363
469
  filteredHits = result.hits.filter((_, i) => indexSet.has(i + 1));
364
470
  ctx.log.debug(`memory_search LLM filter: ${result.hits.length} → ${filteredHits.length} hits, sufficient=${sufficient}`);
365
- } else {
471
+ } else if (searchScope === "local") {
366
472
  return {
367
473
  content: [{ type: "text", text: "No relevant memories found for this query." }],
368
474
  details: { candidates: rawCandidates, filtered: [], meta: result.meta },
369
475
  };
476
+ } else {
477
+ filteredHits = [];
370
478
  }
371
479
  }
372
480
 
481
+ const beforeDedup = filteredHits.length;
482
+ filteredHits = deduplicateHits(filteredHits);
483
+ ctx.log.debug(`memory_search dedup: ${beforeDedup} → ${filteredHits.length}`);
484
+
485
+ const localDetailsHits = filteredHits.map((h) => {
486
+ let effectiveTaskId = h.taskId;
487
+ if (effectiveTaskId) {
488
+ const t = store.getTask(effectiveTaskId);
489
+ if (t && t.status === "skipped") effectiveTaskId = null;
490
+ }
491
+ return {
492
+ ref: h.ref,
493
+ chunkId: h.ref.chunkId,
494
+ taskId: effectiveTaskId,
495
+ skillId: h.skillId,
496
+ role: h.source.role,
497
+ score: h.score,
498
+ summary: h.summary,
499
+ };
500
+ });
501
+
502
+ if (searchScope !== "local") {
503
+ const hub = await hubSearchMemories(store, ctx, { query, maxResults: searchLimit, scope: searchScope as any, hubAddress, userToken }).catch(() => ({ hits: [], meta: { totalCandidates: 0, searchedGroups: [], includedPublic: searchScope === "all" } }));
504
+ const localText = filteredHits.length > 0
505
+ ? filteredHits.map((h, i) => {
506
+ const excerpt = h.original_excerpt.length > 220 ? h.original_excerpt.slice(0, 217) + "..." : h.original_excerpt;
507
+ return `${i + 1}. [${h.source.role}] ${excerpt}`;
508
+ }).join("\n")
509
+ : "(none)";
510
+ const hubText = hub.hits.length > 0
511
+ ? hub.hits.map((h, i) => `${i + 1}. [${h.ownerName}] ${h.summary}${h.groupName ? ` (${h.groupName})` : ""}`).join("\n")
512
+ : "(none)";
513
+
514
+ return {
515
+ content: [{
516
+ type: "text",
517
+ text: `Local results:\n${localText}\n\nHub results:\n${hubText}`,
518
+ }],
519
+ details: {
520
+ local: { hits: localDetailsHits, meta: result.meta },
521
+ hub,
522
+ },
523
+ };
524
+ }
525
+
373
526
  if (filteredHits.length === 0) {
374
527
  return {
375
528
  content: [{ type: "text", text: "No relevant memories found for this query." }],
@@ -377,10 +530,6 @@ const memosLocalPlugin = {
377
530
  };
378
531
  }
379
532
 
380
- const beforeDedup = filteredHits.length;
381
- filteredHits = deduplicateHits(filteredHits);
382
- ctx.log.debug(`memory_search dedup: ${beforeDedup} → ${filteredHits.length}`);
383
-
384
533
  const lines = filteredHits.map((h, i) => {
385
534
  const excerpt = h.original_excerpt;
386
535
  const parts = [`${i + 1}. [${h.source.role}]`];
@@ -473,7 +622,7 @@ const memosLocalPlugin = {
473
622
  if (!anchorChunk) {
474
623
  return {
475
624
  content: [{ type: "text", text: `Chunk not found: ${chunkId}` }],
476
- details: { error: "not_found" },
625
+ details: { error: "not_found", entries: [] },
477
626
  };
478
627
  }
479
628
 
@@ -522,7 +671,7 @@ const memosLocalPlugin = {
522
671
  Type.Number({ description: `Max chars (default ${DEFAULTS.getMaxCharsDefault}, max ${DEFAULTS.getMaxCharsMax})` }),
523
672
  ),
524
673
  }),
525
- execute: trackTool("memory_get", async (_toolCallId: any, params: any) => {
674
+ execute: trackTool("memory_get", async (_toolCallId: any, params: any, context?: any) => {
526
675
  const { chunkId, maxChars } = params as { chunkId: string; maxChars?: number };
527
676
  const limit = Math.min(maxChars ?? DEFAULTS.getMaxCharsDefault, DEFAULTS.getMaxCharsMax);
528
677
 
@@ -629,6 +778,209 @@ const memosLocalPlugin = {
629
778
  { name: "task_summary" },
630
779
  );
631
780
 
781
+ // ─── Tool: task_share ───
782
+
783
+ api.registerTool(
784
+ {
785
+ name: "task_share",
786
+ label: "Task Share",
787
+ description:
788
+ "Share one existing local task and its chunks to the configured hub. " +
789
+ "Minimal MVP path for validating team task sharing.",
790
+ parameters: Type.Object({
791
+ taskId: Type.String({ description: "Local task ID to share" }),
792
+ visibility: Type.Optional(Type.String({ description: "Share visibility: 'public' (default) or 'group'" })),
793
+ groupId: Type.Optional(Type.String({ description: "Optional group ID when visibility='group'" })),
794
+ }),
795
+ execute: trackTool("task_share", async (_toolCallId: any, params: any) => {
796
+ const { taskId, visibility: rawVisibility, groupId } = params as {
797
+ taskId: string;
798
+ visibility?: string;
799
+ groupId?: string;
800
+ };
801
+
802
+ const task = store.getTask(taskId);
803
+ if (!task) {
804
+ return {
805
+ content: [{ type: "text", text: `Task not found: ${taskId}` }],
806
+ details: { error: "not_found", taskId },
807
+ };
808
+ }
809
+
810
+ const chunks = store.getChunksByTask(taskId);
811
+ if (chunks.length === 0) {
812
+ return {
813
+ content: [{ type: "text", text: `Task ${taskId} has no chunks to share.` }],
814
+ details: { error: "no_chunks", taskId },
815
+ };
816
+ }
817
+
818
+ const visibility = rawVisibility === "group" ? "group" : "public";
819
+ const hubClient = await resolveHubClient(store, ctx);
820
+ const { v4: uuidv4 } = require("uuid");
821
+ const hubTaskId = uuidv4();
822
+
823
+ const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/share", {
824
+ method: "POST",
825
+ body: JSON.stringify({
826
+ task: {
827
+ id: hubTaskId,
828
+ sourceTaskId: task.id,
829
+ sourceUserId: hubClient.userId,
830
+ title: task.title,
831
+ summary: task.summary,
832
+ groupId: visibility === "group" ? (groupId ?? null) : null,
833
+ visibility,
834
+ createdAt: task.startedAt,
835
+ updatedAt: task.updatedAt,
836
+ },
837
+ chunks: chunks.map((chunk) => ({
838
+ id: uuidv4(),
839
+ hubTaskId,
840
+ sourceTaskId: task.id,
841
+ sourceChunkId: chunk.id,
842
+ sourceUserId: hubClient.userId,
843
+ role: chunk.role,
844
+ content: chunk.content,
845
+ summary: chunk.summary,
846
+ kind: chunk.kind,
847
+ createdAt: chunk.createdAt,
848
+ })),
849
+ }),
850
+ }) as any;
851
+
852
+ store.markTaskShared(task.id, hubTaskId, chunks.length, visibility, groupId);
853
+
854
+ return {
855
+ content: [{ type: "text", text: `Shared task "${task.title}" with ${chunks.length} chunks to the hub.` }],
856
+ details: {
857
+ shared: true,
858
+ taskId: task.id,
859
+ visibility,
860
+ chunkCount: chunks.length,
861
+ hubUrl: hubClient.hubUrl,
862
+ response,
863
+ },
864
+ };
865
+ }),
866
+ },
867
+ { name: "task_share" },
868
+ );
869
+
870
+ // ─── Tool: task_unshare ───
871
+
872
+ api.registerTool(
873
+ {
874
+ name: "task_unshare",
875
+ label: "Task Unshare",
876
+ description: "Remove one previously shared task from the configured hub.",
877
+ parameters: Type.Object({
878
+ taskId: Type.String({ description: "Local task ID to unshare" }),
879
+ }),
880
+ execute: trackTool("task_unshare", async (_toolCallId: any, params: any) => {
881
+ const { taskId } = params as { taskId: string };
882
+
883
+ const task = store.getTask(taskId);
884
+ if (!task) {
885
+ return {
886
+ content: [{ type: "text", text: `Task not found: ${taskId}` }],
887
+ details: { error: "not_found", taskId },
888
+ };
889
+ }
890
+
891
+ const hubClient = await resolveHubClient(store, ctx);
892
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/unshare", {
893
+ method: "POST",
894
+ body: JSON.stringify({
895
+ sourceUserId: hubClient.userId,
896
+ sourceTaskId: task.id,
897
+ }),
898
+ });
899
+
900
+ store.unmarkTaskShared(task.id);
901
+
902
+ return {
903
+ content: [{ type: "text", text: `Unshared task "${task.title}" from the hub.` }],
904
+ details: {
905
+ unshared: true,
906
+ taskId: task.id,
907
+ hubUrl: hubClient.hubUrl,
908
+ },
909
+ };
910
+ }),
911
+ },
912
+ { name: "task_unshare" },
913
+ );
914
+
915
+ api.registerTool(
916
+ {
917
+ name: "network_memory_detail",
918
+ label: "Network Memory Detail",
919
+ description: "Fetch the full detail for a Hub search hit returned by memory_search(scope=group|all).",
920
+ parameters: Type.Object({
921
+ remoteHitId: Type.String({ description: "The remoteHitId returned by a Hub search hit" }),
922
+ hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override for tests or manual routing" })),
923
+ userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override for tests" })),
924
+ }),
925
+ execute: trackTool("network_memory_detail", async (_toolCallId: any, params: any) => {
926
+ const { remoteHitId, hubAddress, userToken } = params as {
927
+ remoteHitId: string;
928
+ hubAddress?: string;
929
+ userToken?: string;
930
+ };
931
+
932
+ const detail = await hubGetMemoryDetail(store, ctx, { remoteHitId, hubAddress, userToken });
933
+ return {
934
+ content: [{
935
+ type: "text",
936
+ text: `## Shared Memory Detail
937
+
938
+ ${detail.summary}
939
+
940
+ ${detail.content}`,
941
+ }],
942
+ details: detail,
943
+ };
944
+ }),
945
+ },
946
+ { name: "network_memory_detail" },
947
+ );
948
+
949
+ api.registerTool(
950
+ {
951
+ name: "network_team_info",
952
+ label: "Network Team Info",
953
+ description:
954
+ "Show current Hub connection status, signed-in user, role, and group memberships. " +
955
+ "Use this as a preflight check before any Hub share/unshare or Hub pull operation.",
956
+ parameters: Type.Object({}),
957
+ execute: trackTool("network_team_info", async () => {
958
+ const status = await getHubStatus(store, ctx.config);
959
+ if (!status.connected || !status.user) {
960
+ return {
961
+ content: [{ type: "text", text: "Hub is not connected." }],
962
+ details: status,
963
+ };
964
+ }
965
+
966
+ const groupNames = status.user.groups.map((group) => group.name);
967
+ return {
968
+ content: [{
969
+ type: "text",
970
+ text: `## Team Connection
971
+
972
+ User: ${status.user.username}
973
+ Role: ${status.user.role}
974
+ Hub: ${status.hubUrl ?? "(unknown)"}
975
+ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
976
+ }],
977
+ details: status,
978
+ };
979
+ }),
980
+ },
981
+ { name: "network_team_info" },
982
+ );
983
+
632
984
  // ─── Tool: skill_get ───
633
985
 
634
986
  api.registerTool(
@@ -745,7 +1097,6 @@ const memosLocalPlugin = {
745
1097
  parameters: Type.Object({}),
746
1098
  execute: trackTool("memory_viewer", async () => {
747
1099
  ctx.log.debug(`memory_viewer called`);
748
- telemetry.trackViewerOpened();
749
1100
  const url = `http://127.0.0.1:${viewerPort}`;
750
1101
  return {
751
1102
  content: [
@@ -776,12 +1127,13 @@ const memosLocalPlugin = {
776
1127
  api.registerTool(
777
1128
  {
778
1129
  name: "memory_write_public",
779
- label: "Write Public Memory",
1130
+ label: "Write Local Shared Memory",
780
1131
  description:
781
- "Write a piece of information to public memory. Public memories are visible to all agents during memory_search. " +
782
- "Use this for shared knowledge, team decisions, or cross-agent coordination information.",
1132
+ "Write a piece of information to local shared memory for all agents in this OpenClaw workspace. " +
1133
+ "Use this when you are creating a new shared note from scratch. This does not publish to Hub. " +
1134
+ "If you already have a memory chunk and want to expose it, use memory_share instead.",
783
1135
  parameters: Type.Object({
784
- content: Type.String({ description: "The content to write to public memory" }),
1136
+ content: Type.String({ description: "The content to write to local shared memory" }),
785
1137
  summary: Type.Optional(Type.String({ description: "Optional short summary of the content" })),
786
1138
  }),
787
1139
  execute: trackTool("memory_write_public", async (_toolCallId: any, params: any) => {
@@ -826,7 +1178,7 @@ const memosLocalPlugin = {
826
1178
  }
827
1179
 
828
1180
  return {
829
- content: [{ type: "text", text: `Public memory written successfully (id: ${chunkId}).` }],
1181
+ content: [{ type: "text", text: `Memory shared to local agents successfully (id: ${chunkId}).` }],
830
1182
  details: { chunkId, owner: "public" },
831
1183
  };
832
1184
  }),
@@ -834,6 +1186,164 @@ const memosLocalPlugin = {
834
1186
  { name: "memory_write_public" },
835
1187
  );
836
1188
 
1189
+ api.registerTool(
1190
+ {
1191
+ name: "memory_share",
1192
+ label: "Share Memory",
1193
+ description:
1194
+ "Share an existing memory either with local OpenClaw agents, to the Hub team, or to both targets. " +
1195
+ "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. " +
1196
+ "If you need to create a brand new shared memory instead of exposing an existing one, use memory_write_public.",
1197
+ parameters: Type.Object({
1198
+ chunkId: Type.String({ description: "Existing local memory chunk ID to share" }),
1199
+ target: Type.Optional(Type.String({ description: "Share target: 'agents' (default), 'hub', or 'both'" })),
1200
+ visibility: Type.Optional(Type.String({ description: "Hub visibility when target includes hub: 'public' (default) or 'group'" })),
1201
+ groupId: Type.Optional(Type.String({ description: "Optional Hub group ID when visibility='group'" })),
1202
+ hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override" })),
1203
+ userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override" })),
1204
+ }),
1205
+ execute: trackTool("memory_share", async (_toolCallId: any, params: any) => {
1206
+ const {
1207
+ chunkId,
1208
+ target: rawTarget,
1209
+ visibility: rawVisibility,
1210
+ groupId,
1211
+ hubAddress,
1212
+ userToken,
1213
+ } = params as {
1214
+ chunkId: string;
1215
+ target?: string;
1216
+ visibility?: string;
1217
+ groupId?: string;
1218
+ hubAddress?: string;
1219
+ userToken?: string;
1220
+ };
1221
+
1222
+ const chunk = store.getChunk(chunkId);
1223
+ if (!chunk) {
1224
+ return { content: [{ type: "text", text: `Memory not found: ${chunkId}` }], details: { error: "not_found", chunkId } };
1225
+ }
1226
+
1227
+ const target = resolveMemoryShareTarget(rawTarget);
1228
+ const visibility = rawVisibility === "group" ? "group" : "public";
1229
+ const details: Record<string, unknown> = { chunkId, target };
1230
+ const messages: string[] = [];
1231
+
1232
+ if (target === "agents" || target === "both") {
1233
+ const local = store.markMemorySharedLocally(chunkId);
1234
+ if (!local.ok) {
1235
+ return { content: [{ type: "text", text: `Failed to share memory ${chunkId} to local agents.` }], details: { error: local.reason ?? "local_share_failed", chunkId, target } };
1236
+ }
1237
+ details.local = {
1238
+ shared: true,
1239
+ owner: local.owner,
1240
+ originalOwner: local.originalOwner ?? null,
1241
+ };
1242
+ messages.push("shared to local agents");
1243
+ }
1244
+
1245
+ if (target === "hub" || target === "both") {
1246
+ const hub = await shareMemoryToHub(chunkId, { visibility, groupId, hubAddress, userToken });
1247
+ details.hub = {
1248
+ shared: true,
1249
+ memoryId: hub.memoryId,
1250
+ visibility: hub.visibility,
1251
+ groupId: hub.groupId,
1252
+ };
1253
+ messages.push(`shared to Hub (${hub.visibility})`);
1254
+ }
1255
+
1256
+ return {
1257
+ content: [{ type: "text", text: `Memory "${chunk.summary || chunk.id}" ${messages.join(" and ")}.` }],
1258
+ details,
1259
+ };
1260
+ }),
1261
+ },
1262
+ { name: "memory_share" },
1263
+ );
1264
+
1265
+ api.registerTool(
1266
+ {
1267
+ name: "memory_unshare",
1268
+ label: "Unshare Memory",
1269
+ description:
1270
+ "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. " +
1271
+ "privateOwner is only needed for older public memories that were never tracked with an original owner.",
1272
+ parameters: Type.Object({
1273
+ chunkId: Type.String({ description: "Existing local memory chunk ID to unshare" }),
1274
+ target: Type.Optional(Type.String({ description: "Unshare target: 'agents', 'hub', or 'all' (default)" })),
1275
+ privateOwner: Type.Optional(Type.String({ description: "Optional owner to restore when converting a public memory back to private and no original owner was tracked" })),
1276
+ hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override" })),
1277
+ userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override" })),
1278
+ }),
1279
+ execute: trackTool("memory_unshare", async (_toolCallId: any, params: any) => {
1280
+ const {
1281
+ chunkId,
1282
+ target: rawTarget,
1283
+ privateOwner,
1284
+ hubAddress,
1285
+ userToken,
1286
+ } = params as {
1287
+ chunkId: string;
1288
+ target?: string;
1289
+ privateOwner?: string;
1290
+ hubAddress?: string;
1291
+ userToken?: string;
1292
+ };
1293
+
1294
+ const chunk = store.getChunk(chunkId);
1295
+ if (!chunk) {
1296
+ return { content: [{ type: "text", text: `Memory not found: ${chunkId}` }], details: { error: "not_found", chunkId } };
1297
+ }
1298
+
1299
+ const target = resolveMemoryUnshareTarget(rawTarget);
1300
+ const details: Record<string, unknown> = { chunkId, target };
1301
+ const messages: string[] = [];
1302
+
1303
+ if (target === "agents" || target === "all") {
1304
+ const local = store.unmarkMemorySharedLocally(chunkId, privateOwner);
1305
+ if (!local.ok) {
1306
+ return {
1307
+ content: [{
1308
+ type: "text",
1309
+ text: local.reason === "original_owner_missing"
1310
+ ? `Cannot restore memory "${chunk.summary || chunk.id}" to a private owner automatically. Pass privateOwner to unshare it locally.`
1311
+ : `Failed to stop local sharing for memory ${chunkId}.`,
1312
+ }],
1313
+ details: { error: local.reason ?? "local_unshare_failed", chunkId, target },
1314
+ };
1315
+ }
1316
+ details.local = {
1317
+ shared: false,
1318
+ owner: local.owner,
1319
+ };
1320
+ messages.push("removed from local agent sharing");
1321
+ }
1322
+
1323
+ if (target === "hub" || target === "all") {
1324
+ try {
1325
+ await unshareMemoryFromHub(chunkId, { hubAddress, userToken });
1326
+ details.hub = { shared: false };
1327
+ messages.push("removed from Hub");
1328
+ } catch (err) {
1329
+ const msg = err instanceof Error ? err.message : String(err);
1330
+ if (target === "all" && msg.includes("hub client connection is not configured")) {
1331
+ details.hub = { shared: false, skipped: true, reason: "hub_not_configured" };
1332
+ } else {
1333
+ throw err;
1334
+ }
1335
+ }
1336
+ }
1337
+
1338
+ return {
1339
+ content: [{ type: "text", text: `Memory "${chunk.summary || chunk.id}" ${messages.join(" and ")}.` }],
1340
+ details,
1341
+ };
1342
+ }),
1343
+ },
1344
+ { name: "memory_unshare" },
1345
+ );
1346
+
837
1347
  // ─── Tool: skill_search ───
838
1348
 
839
1349
  api.registerTool(
@@ -841,16 +1351,42 @@ const memosLocalPlugin = {
841
1351
  name: "skill_search",
842
1352
  label: "Skill Search",
843
1353
  description:
844
- "Search available skills by natural language. Searches your own skills, public skills, or both. " +
845
- "Use when you need a capability or guide and don't have a matching skill at hand.",
1354
+ "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. " +
1355
+ "Use this when you need a capability or guide and don't have a matching skill at hand.",
846
1356
  parameters: Type.Object({
847
1357
  query: Type.String({ description: "Natural language description of the needed skill" }),
848
- scope: Type.Optional(Type.String({ description: "Search scope: 'mix' (default, self + public), 'self' (own only), 'public' (public only)" })),
1358
+ scope: Type.Optional(Type.String({ description: "Search scope: 'mix' (default), 'self', 'public', 'group', or 'all'." })),
849
1359
  }),
850
- execute: trackTool("skill_search", async (_toolCallId: any, params: any) => {
1360
+ execute: trackTool("skill_search", async (_toolCallId: any, params: any, context?: any) => {
851
1361
  const { query: skillQuery, scope: rawScope } = params as { query: string; scope?: string };
852
1362
  const scope = (rawScope === "self" || rawScope === "public") ? rawScope : "mix";
853
- const currentOwner = `agent:${currentAgentId}`;
1363
+ const currentOwner = getCurrentOwner();
1364
+
1365
+ if (rawScope === "group" || rawScope === "all") {
1366
+ const [localHits, hub] = await Promise.all([
1367
+ engine.searchSkills(skillQuery, "mix" as any, currentOwner),
1368
+ hubSearchSkills(store, ctx, { query: skillQuery, maxResults: 10 }).catch(() => ({ hits: [] })),
1369
+ ]);
1370
+
1371
+ if (localHits.length === 0 && hub.hits.length === 0) {
1372
+ return {
1373
+ content: [{ type: "text", text: `No relevant skills found for: "${skillQuery}" (scope: ${rawScope})` }],
1374
+ details: { query: skillQuery, scope: rawScope, local: { hits: [] }, hub },
1375
+ };
1376
+ }
1377
+
1378
+ const localText = localHits.length > 0
1379
+ ? localHits.map((h, i) => `${i + 1}. [${h.name}] ${h.description.slice(0, 150)}${h.visibility === "public" ? " (shared to local agents)" : ""}`).join("\n")
1380
+ : "(none)";
1381
+ const hubText = hub.hits.length > 0
1382
+ ? 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")
1383
+ : "(none)";
1384
+
1385
+ return {
1386
+ content: [{ type: "text", text: `Local skills:\n${localText}\n\nHub skills:\n${hubText}` }],
1387
+ details: { query: skillQuery, scope: rawScope, local: { hits: localHits }, hub },
1388
+ };
1389
+ }
854
1390
 
855
1391
  const hits = await engine.searchSkills(skillQuery, scope as any, currentOwner);
856
1392
 
@@ -862,7 +1398,7 @@ const memosLocalPlugin = {
862
1398
  }
863
1399
 
864
1400
  const text = hits.map((h, i) =>
865
- `${i + 1}. [${h.name}] ${h.description}${h.visibility === "public" ? " (public)" : ""}`,
1401
+ `${i + 1}. [${h.name}] ${h.description}${h.visibility === "public" ? " (shared to local agents)" : ""}`,
866
1402
  ).join("\n");
867
1403
 
868
1404
  return {
@@ -880,20 +1416,54 @@ const memosLocalPlugin = {
880
1416
  {
881
1417
  name: "skill_publish",
882
1418
  label: "Publish Skill",
883
- description: "Make a skill public so other agents can discover and install it via skill_search.",
1419
+ description:
1420
+ "Share a skill with local agents or publish it to the Hub. " +
1421
+ "Use target='agents' for local sharing, or target='hub' with visibility='public'/'group' for Hub publishing. " +
1422
+ "The old scope parameter is still accepted for backward compatibility.",
884
1423
  parameters: Type.Object({
885
1424
  skillId: Type.String({ description: "The skill ID to publish" }),
1425
+ target: Type.Optional(Type.String({ description: "Publish target: 'agents' (default) or 'hub'." })),
1426
+ visibility: Type.Optional(Type.String({ description: "Hub visibility when target='hub': 'public' (default) or 'group'." })),
1427
+ scope: Type.Optional(Type.String({ description: "Deprecated alias: omit for local agents, or use 'public' / 'group' to publish to Hub." })),
1428
+ groupId: Type.Optional(Type.String({ description: "Optional group ID when scope='group'" })),
1429
+ hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override for tests or manual routing" })),
1430
+ userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override for tests" })),
886
1431
  }),
887
1432
  execute: trackTool("skill_publish", async (_toolCallId: any, params: any) => {
888
- const { skillId: pubSkillId } = params as { skillId: string };
1433
+ const {
1434
+ skillId: pubSkillId,
1435
+ target: rawTarget,
1436
+ visibility: rawVisibility,
1437
+ scope,
1438
+ groupId,
1439
+ hubAddress,
1440
+ userToken,
1441
+ } = params as {
1442
+ skillId: string;
1443
+ target?: string;
1444
+ visibility?: string;
1445
+ scope?: string;
1446
+ groupId?: string;
1447
+ hubAddress?: string;
1448
+ userToken?: string;
1449
+ };
889
1450
  const skill = store.getSkill(pubSkillId);
890
1451
  if (!skill) {
891
1452
  return { content: [{ type: "text", text: `Skill not found: ${pubSkillId}` }] };
892
1453
  }
1454
+ const target = resolveSkillPublishTarget(rawTarget, scope);
1455
+ const visibility = resolveSkillHubVisibility(rawVisibility, scope);
1456
+ if (target === "hub") {
1457
+ const published = await publishSkillBundleToHub(store, ctx, { skillId: pubSkillId, visibility, groupId, hubAddress, userToken });
1458
+ return {
1459
+ content: [{ type: "text", text: `Skill "${skill.name}" shared to Hub (${published.visibility}).` }],
1460
+ details: { skillId: pubSkillId, name: skill.name, target, publishedToHub: true, hubSkillId: published.skillId, visibility: published.visibility },
1461
+ };
1462
+ }
893
1463
  store.setSkillVisibility(pubSkillId, "public");
894
1464
  return {
895
- content: [{ type: "text", text: `Skill "${skill.name}" is now public.` }],
896
- details: { skillId: pubSkillId, name: skill.name, visibility: "public" },
1465
+ content: [{ type: "text", text: `Skill "${skill.name}" is now shared with local agents.` }],
1466
+ details: { skillId: pubSkillId, name: skill.name, target, visibility: "public", publishedToHub: false },
897
1467
  };
898
1468
  }),
899
1469
  },
@@ -906,30 +1476,78 @@ const memosLocalPlugin = {
906
1476
  {
907
1477
  name: "skill_unpublish",
908
1478
  label: "Unpublish Skill",
909
- description: "Make a skill private. Other agents will no longer be able to discover it.",
1479
+ description:
1480
+ "Stop sharing a skill with local agents, remove it from the Hub, or do both. " +
1481
+ "Use target='agents' (default), 'hub', or 'all'.",
910
1482
  parameters: Type.Object({
911
1483
  skillId: Type.String({ description: "The skill ID to unpublish" }),
1484
+ target: Type.Optional(Type.String({ description: "Unpublish target: 'agents' (default), 'hub', or 'all'." })),
1485
+ hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override for tests or manual routing" })),
1486
+ userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override for tests" })),
912
1487
  }),
913
1488
  execute: trackTool("skill_unpublish", async (_toolCallId: any, params: any) => {
914
- const { skillId: unpubSkillId } = params as { skillId: string };
1489
+ const { skillId: unpubSkillId, target, hubAddress, userToken } = params as { skillId: string; target?: string; hubAddress?: string; userToken?: string };
915
1490
  const skill = store.getSkill(unpubSkillId);
916
1491
  if (!skill) {
917
1492
  return { content: [{ type: "text", text: `Skill not found: ${unpubSkillId}` }] };
918
1493
  }
919
- store.setSkillVisibility(unpubSkillId, "private");
1494
+ const resolvedTarget = resolveSkillUnpublishTarget(target);
1495
+ const messages: string[] = [];
1496
+ const details: Record<string, unknown> = { skillId: unpubSkillId, name: skill.name, target: resolvedTarget };
1497
+ if (resolvedTarget === "hub" || resolvedTarget === "all") {
1498
+ try {
1499
+ await unpublishSkillBundleFromHub(store, ctx, { skillId: unpubSkillId, hubAddress, userToken });
1500
+ details.hub = { unpublished: true };
1501
+ messages.push("removed from Hub sharing");
1502
+ } catch (err) {
1503
+ const msg = err instanceof Error ? err.message : String(err);
1504
+ if (resolvedTarget === "all" && msg.includes("hub client connection is not configured")) {
1505
+ details.hub = { unpublished: false, skipped: true, reason: "hub_not_configured" };
1506
+ } else {
1507
+ throw err;
1508
+ }
1509
+ }
1510
+ }
1511
+ if (resolvedTarget === "agents" || resolvedTarget === "all") {
1512
+ store.setSkillVisibility(unpubSkillId, "private");
1513
+ details.local = { visibility: "private" };
1514
+ messages.push("limited to this agent");
1515
+ }
920
1516
  return {
921
- content: [{ type: "text", text: `Skill "${skill.name}" is now private.` }],
922
- details: { skillId: unpubSkillId, name: skill.name, visibility: "private" },
1517
+ content: [{ type: "text", text: `Skill "${skill.name}" ${messages.join(" and ")}.` }],
1518
+ details,
923
1519
  };
924
1520
  }),
925
1521
  },
926
1522
  { name: "skill_unpublish" },
927
1523
  );
928
1524
 
1525
+ api.registerTool(
1526
+ {
1527
+ name: "network_skill_pull",
1528
+ label: "Network Skill Pull",
1529
+ description: "Download a published Hub skill bundle and restore it into local managed skills.",
1530
+ parameters: Type.Object({
1531
+ skillId: Type.String({ description: "The Hub skill ID to pull" }),
1532
+ hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override for tests or manual routing" })),
1533
+ userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override for tests" })),
1534
+ }),
1535
+ execute: trackTool("network_skill_pull", async (_toolCallId: any, params: any) => {
1536
+ const { skillId, hubAddress, userToken } = params as { skillId: string; hubAddress?: string; userToken?: string };
1537
+ const payload = await fetchHubSkillBundle(store, ctx, { skillId, hubAddress, userToken });
1538
+ const restored = restoreSkillBundleFromHub(store, ctx, payload);
1539
+ return {
1540
+ content: [{ type: "text", text: `Pulled Hub skill "${restored.localName}" into local storage.` }],
1541
+ details: { pulled: true, hubSkillId: skillId, localSkillId: restored.localSkillId, localName: restored.localName, dirPath: restored.dirPath },
1542
+ };
1543
+ }),
1544
+ },
1545
+ { name: "network_skill_pull" },
1546
+ );
1547
+
929
1548
  // ─── Auto-recall: inject relevant memories before agent starts ───
930
1549
 
931
1550
  api.on("before_agent_start", async (event: { prompt?: string; messages?: unknown[] }, hookCtx?: { agentId?: string; sessionKey?: string }) => {
932
- if (!allowPromptInjection) return {};
933
1551
  if (!event.prompt || event.prompt.length < 3) return;
934
1552
 
935
1553
  const recallAgentId = hookCtx?.agentId ?? "main";
@@ -1231,11 +1849,74 @@ const memosLocalPlugin = {
1231
1849
  worker.enqueue(captured);
1232
1850
  telemetry.trackMemoryIngested(captured.length);
1233
1851
  }
1852
+
1853
+ // Incremental push: sync new chunks for already-shared tasks
1854
+ syncSharedTasksIncremental().catch((err) => {
1855
+ ctx.log.warn(`incremental sync failed: ${err}`);
1856
+ });
1234
1857
  } catch (err) {
1235
1858
  api.logger.warn(`memos-local: capture failed: ${String(err)}`);
1236
1859
  }
1237
1860
  });
1238
1861
 
1862
+ async function syncSharedTasksIncremental(): Promise<void> {
1863
+ if (!ctx.config.sharing?.enabled || ctx.config.sharing.role !== "client") return;
1864
+ const shared = store.listLocalSharedTasks();
1865
+ if (shared.length === 0) return;
1866
+
1867
+ let hubClient: { hubUrl: string; userToken: string; userId: string } | undefined;
1868
+ try {
1869
+ hubClient = await resolveHubClient(store, ctx);
1870
+ } catch {
1871
+ return;
1872
+ }
1873
+ const { v4: uuidv4 } = require("uuid");
1874
+
1875
+ for (const entry of shared) {
1876
+ const task = store.getTask(entry.taskId);
1877
+ if (!task) continue;
1878
+ const chunks = store.getChunksByTask(entry.taskId);
1879
+ if (chunks.length <= entry.syncedChunks) continue;
1880
+
1881
+ const newChunks = chunks.slice(entry.syncedChunks);
1882
+ ctx.log.info(`incremental sync: task=${entry.taskId} pushing ${newChunks.length} new chunk(s)`);
1883
+
1884
+ try {
1885
+ await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/share", {
1886
+ method: "POST",
1887
+ body: JSON.stringify({
1888
+ task: {
1889
+ id: entry.hubTaskId,
1890
+ sourceTaskId: entry.taskId,
1891
+ sourceUserId: hubClient.userId,
1892
+ title: task.title,
1893
+ summary: task.summary,
1894
+ groupId: entry.visibility === "group" ? entry.groupId ?? null : null,
1895
+ visibility: entry.visibility,
1896
+ createdAt: task.startedAt ?? task.updatedAt ?? Date.now(),
1897
+ updatedAt: task.updatedAt ?? Date.now(),
1898
+ },
1899
+ chunks: newChunks.map((chunk) => ({
1900
+ id: uuidv4(),
1901
+ hubTaskId: entry.hubTaskId,
1902
+ sourceTaskId: entry.taskId,
1903
+ sourceChunkId: chunk.id,
1904
+ sourceUserId: hubClient.userId,
1905
+ role: chunk.role,
1906
+ content: chunk.content,
1907
+ summary: chunk.summary,
1908
+ kind: chunk.kind,
1909
+ createdAt: chunk.createdAt,
1910
+ })),
1911
+ }),
1912
+ });
1913
+ store.markTaskShared(entry.taskId, entry.hubTaskId, chunks.length, entry.visibility, entry.groupId);
1914
+ } catch (err) {
1915
+ ctx.log.warn(`incremental sync failed for task=${entry.taskId}: ${err}`);
1916
+ }
1917
+ }
1918
+ }
1919
+
1239
1920
  // ─── Memory Viewer (web UI) ───
1240
1921
 
1241
1922
  const viewer = new ViewerServer({
@@ -1247,11 +1928,30 @@ const memosLocalPlugin = {
1247
1928
  ctx,
1248
1929
  });
1249
1930
 
1931
+ const hubServer = ctx.config.sharing?.enabled && ctx.config.sharing.role === "hub"
1932
+ ? new HubServer({ store, log: ctx.log, config: ctx.config, dataDir: stateDir, embedder })
1933
+ : null;
1934
+
1250
1935
  // ─── Service lifecycle ───
1251
1936
 
1252
1937
  api.registerService({
1253
1938
  id: "memos-local-openclaw-plugin",
1254
1939
  start: async () => {
1940
+ if (hubServer) {
1941
+ const hubUrl = await hubServer.start();
1942
+ api.logger.info(`memos-local: hub started at ${hubUrl}`);
1943
+ }
1944
+
1945
+ // Auto-connect to Hub in client mode (handles both existing token and auto-join via teamToken)
1946
+ if (ctx.config.sharing?.enabled && ctx.config.sharing.role === "client") {
1947
+ try {
1948
+ const session = await connectToHub(store, ctx.config, ctx.log);
1949
+ api.logger.info(`memos-local: connected to Hub as "${session.username}" (${session.userId})`);
1950
+ } catch (err) {
1951
+ api.logger.warn(`memos-local: Hub connection failed: ${err}`);
1952
+ }
1953
+ }
1954
+
1255
1955
  try {
1256
1956
  const viewerUrl = await viewer.start();
1257
1957
  api.logger.info(`memos-local: started (embedding: ${embedder.provider})`);
@@ -1277,7 +1977,9 @@ const memosLocalPlugin = {
1277
1977
  );
1278
1978
  },
1279
1979
  stop: async () => {
1980
+ await worker.flush();
1280
1981
  await telemetry.shutdown();
1982
+ await hubServer?.stop();
1281
1983
  viewer.stop();
1282
1984
  store.close();
1283
1985
  api.logger.info("memos-local: stopped");