@memtensor/memos-local-openclaw-plugin 1.0.5 → 1.0.6-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/capture/index.d.ts.map +1 -1
  2. package/dist/capture/index.js +24 -0
  3. package/dist/capture/index.js.map +1 -1
  4. package/dist/client/connector.d.ts.map +1 -1
  5. package/dist/client/connector.js +33 -5
  6. package/dist/client/connector.js.map +1 -1
  7. package/dist/client/hub.d.ts.map +1 -1
  8. package/dist/client/hub.js +4 -0
  9. package/dist/client/hub.js.map +1 -1
  10. package/dist/hub/server.d.ts +2 -0
  11. package/dist/hub/server.d.ts.map +1 -1
  12. package/dist/hub/server.js +116 -54
  13. package/dist/hub/server.js.map +1 -1
  14. package/dist/ingest/providers/index.d.ts +4 -0
  15. package/dist/ingest/providers/index.d.ts.map +1 -1
  16. package/dist/ingest/providers/index.js +32 -86
  17. package/dist/ingest/providers/index.js.map +1 -1
  18. package/dist/ingest/providers/openai.d.ts.map +1 -1
  19. package/dist/ingest/providers/openai.js +29 -13
  20. package/dist/ingest/providers/openai.js.map +1 -1
  21. package/dist/recall/engine.d.ts.map +1 -1
  22. package/dist/recall/engine.js +33 -32
  23. package/dist/recall/engine.js.map +1 -1
  24. package/dist/storage/sqlite.d.ts +43 -7
  25. package/dist/storage/sqlite.d.ts.map +1 -1
  26. package/dist/storage/sqlite.js +179 -58
  27. package/dist/storage/sqlite.js.map +1 -1
  28. package/dist/tools/memory-get.d.ts.map +1 -1
  29. package/dist/tools/memory-get.js +4 -1
  30. package/dist/tools/memory-get.js.map +1 -1
  31. package/dist/types.d.ts +1 -1
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/types.js.map +1 -1
  34. package/dist/update-check.d.ts.map +1 -1
  35. package/dist/update-check.js +2 -7
  36. package/dist/update-check.js.map +1 -1
  37. package/dist/viewer/html.d.ts.map +1 -1
  38. package/dist/viewer/html.js +115 -27
  39. package/dist/viewer/html.js.map +1 -1
  40. package/dist/viewer/server.d.ts +25 -0
  41. package/dist/viewer/server.d.ts.map +1 -1
  42. package/dist/viewer/server.js +503 -206
  43. package/dist/viewer/server.js.map +1 -1
  44. package/index.ts +273 -282
  45. package/openclaw.plugin.json +1 -1
  46. package/package.json +2 -1
  47. package/scripts/native-binding.cjs +32 -0
  48. package/scripts/postinstall.cjs +24 -11
  49. package/src/capture/index.ts +36 -0
  50. package/src/client/connector.ts +32 -5
  51. package/src/client/hub.ts +4 -0
  52. package/src/hub/server.ts +110 -50
  53. package/src/ingest/providers/index.ts +37 -92
  54. package/src/ingest/providers/openai.ts +31 -13
  55. package/src/recall/engine.ts +32 -30
  56. package/src/storage/sqlite.ts +196 -63
  57. package/src/tools/memory-get.ts +4 -1
  58. package/src/types.ts +2 -0
  59. package/src/update-check.ts +2 -7
  60. package/src/viewer/html.ts +115 -27
  61. package/src/viewer/server.ts +483 -172
  62. package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
  63. package/prebuilds/darwin-x64/better_sqlite3.node +0 -0
  64. package/prebuilds/linux-x64/better_sqlite3.node +0 -0
  65. package/prebuilds/win32-x64/better_sqlite3.node +0 -0
  66. package/telemetry.credentials.json +0 -5
package/index.ts CHANGED
@@ -9,6 +9,7 @@ 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 { createRequire } from "node:module";
12
13
  import { fileURLToPath } from "url";
13
14
  import { buildContext } from "./src/config";
14
15
  import type { HostModelsConfig } from "./src/openclaw-api";
@@ -83,25 +84,56 @@ const memosLocalPlugin = {
83
84
  configSchema: pluginConfigSchema,
84
85
 
85
86
  register(api: OpenClawPluginApi) {
86
- // ─── Ensure better-sqlite3 native module is available ───
87
- const pluginDir = path.dirname(fileURLToPath(import.meta.url));
87
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
88
+ const localRequire = createRequire(import.meta.url);
89
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
90
+
91
+ function detectPluginDir(startDir: string): string {
92
+ let cur = startDir;
93
+ for (let i = 0; i < 6; i++) {
94
+ const pkg = path.join(cur, "package.json");
95
+ if (fs.existsSync(pkg)) return cur;
96
+ const parent = path.dirname(cur);
97
+ if (parent === cur) break;
98
+ cur = parent;
99
+ }
100
+ return startDir;
101
+ }
102
+
103
+ const pluginDir = detectPluginDir(moduleDir);
88
104
 
89
105
  function normalizeFsPath(p: string): string {
90
- return path.resolve(p).replace(/\\/g, "/").toLowerCase();
106
+ return path.resolve(p).replace(/^\\\\\?\\/, "").toLowerCase();
107
+ }
108
+
109
+ function isPathInside(baseDir: string, targetPath: string): boolean {
110
+ const baseNorm = normalizeFsPath(baseDir);
111
+ const targetNorm = normalizeFsPath(targetPath);
112
+ const rel = path.relative(baseNorm, targetNorm);
113
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
114
+ }
115
+
116
+ function runNpm(args: string[]) {
117
+ const { spawnSync } = localRequire("child_process") as typeof import("node:child_process");
118
+ return spawnSync(npmCmd, args, {
119
+ cwd: pluginDir,
120
+ stdio: "pipe",
121
+ shell: false,
122
+ timeout: 120_000,
123
+ });
91
124
  }
92
125
 
93
126
  let sqliteReady = false;
94
127
 
95
128
  function trySqliteLoad(): boolean {
96
129
  try {
97
- const resolved = require.resolve("better-sqlite3", { paths: [pluginDir] });
98
- const resolvedNorm = normalizeFsPath(resolved);
99
- const pluginNorm = normalizeFsPath(pluginDir);
100
- if (!resolvedNorm.startsWith(pluginNorm + "/") && resolvedNorm !== pluginNorm) {
130
+ const resolved = localRequire.resolve("better-sqlite3", { paths: [pluginDir] });
131
+ const resolvedReal = fs.existsSync(resolved) ? fs.realpathSync.native(resolved) : resolved;
132
+ if (!isPathInside(pluginDir, resolvedReal)) {
101
133
  api.logger.warn(`memos-local: better-sqlite3 resolved outside plugin dir: ${resolved}`);
102
134
  return false;
103
135
  }
104
- require(resolved);
136
+ localRequire(resolvedReal);
105
137
  return true;
106
138
  } catch {
107
139
  return false;
@@ -114,13 +146,7 @@ const memosLocalPlugin = {
114
146
  api.logger.warn(`memos-local: better-sqlite3 not found in ${pluginDir}, attempting auto-rebuild ...`);
115
147
 
116
148
  try {
117
- const { spawnSync } = require("child_process");
118
- const rebuildResult = spawnSync("npm", ["rebuild", "better-sqlite3"], {
119
- cwd: pluginDir,
120
- stdio: "pipe",
121
- shell: true,
122
- timeout: 120_000,
123
- });
149
+ const rebuildResult = runNpm(["rebuild", "better-sqlite3"]);
124
150
 
125
151
  const stdout = rebuildResult.stdout?.toString() || "";
126
152
  const stderr = rebuildResult.stderr?.toString() || "";
@@ -128,9 +154,9 @@ const memosLocalPlugin = {
128
154
  if (stderr) api.logger.warn(`memos-local: rebuild stderr: ${stderr.slice(0, 500)}`);
129
155
 
130
156
  if (rebuildResult.status === 0) {
131
- Object.keys(require.cache)
157
+ Object.keys(localRequire.cache)
132
158
  .filter(k => k.includes("better-sqlite3") || k.includes("better_sqlite3"))
133
- .forEach(k => delete require.cache[k]);
159
+ .forEach(k => delete localRequire.cache[k]);
134
160
  sqliteReady = trySqliteLoad();
135
161
  if (sqliteReady) {
136
162
  api.logger.info("memos-local: better-sqlite3 auto-rebuild succeeded!");
@@ -222,7 +248,7 @@ const memosLocalPlugin = {
222
248
 
223
249
  let pluginVersion = "0.0.0";
224
250
  try {
225
- const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf-8"));
251
+ const pkg = JSON.parse(fs.readFileSync(path.join(pluginDir, "package.json"), "utf-8"));
226
252
  pluginVersion = pkg.version ?? pluginVersion;
227
253
  } catch {}
228
254
  const telemetry = new Telemetry(ctx.config.telemetry ?? {}, stateDir, pluginVersion, ctx.log, pluginDir);
@@ -314,25 +340,11 @@ const memosLocalPlugin = {
314
340
  try {
315
341
  let outputText: string;
316
342
  const det = result?.details;
317
- if (det && Array.isArray(det.candidates)) {
318
- outputText = JSON.stringify({
319
- candidates: det.candidates,
320
- filtered: det.hits ?? det.filtered ?? [],
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
- }));
343
+ if (det && (Array.isArray(det.candidates) || Array.isArray(det.filtered))) {
333
344
  outputText = JSON.stringify({
334
- candidates: [...localHits, ...hubHits],
335
- filtered: [...localHits, ...hubHits],
345
+ candidates: det.candidates ?? [],
346
+ hubCandidates: det.hubCandidates ?? [],
347
+ filtered: det.filtered ?? det.hits ?? [],
336
348
  });
337
349
  } else {
338
350
  outputText = result?.content?.[0]?.text ?? JSON.stringify(result ?? "");
@@ -406,7 +418,8 @@ const memosLocalPlugin = {
406
418
  updatedAt: now,
407
419
  });
408
420
  } else if (ctx.config.sharing?.enabled && hubClient.userId) {
409
- store.upsertTeamSharedChunk(chunk.id, { hubMemoryId: memoryId, visibility, groupId });
421
+ const conn = store.getClientHubConnection();
422
+ store.upsertTeamSharedChunk(chunk.id, { hubMemoryId: memoryId, visibility, groupId, hubInstanceId: conn?.hubInstanceId ?? "" });
410
423
  }
411
424
 
412
425
  return { memoryId, visibility, groupId };
@@ -448,7 +461,7 @@ const memosLocalPlugin = {
448
461
  hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override for group/all search." })),
449
462
  userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override for group/all search." })),
450
463
  }),
451
- execute: trackTool("memory_search", async (_toolCallId: any, params: any) => {
464
+ execute: trackTool("memory_search", async (_toolCallId: any, params: any, context?: any) => {
452
465
  const {
453
466
  query,
454
467
  scope: rawScope,
@@ -474,14 +487,26 @@ const memosLocalPlugin = {
474
487
  }
475
488
  const searchLimit = typeof maxResults === "number" ? Math.max(1, Math.min(20, Math.round(maxResults))) : 10;
476
489
 
477
- const agentId = currentAgentId;
478
- const ownerFilter = [getCurrentOwner(), "public"];
490
+ const agentId = context?.agentId ?? currentAgentId;
491
+ const ownerFilter = [`agent:${agentId}`, "public"];
479
492
  const effectiveMaxResults = searchLimit;
480
493
  ctx.log.debug(`memory_search query="${query}" maxResults=${effectiveMaxResults} minScore=${minScore ?? 0.45} role=${role ?? "all"} owner=agent:${agentId}`);
481
- const result = await engine.search({ query, maxResults: effectiveMaxResults, minScore, role, ownerFilter });
482
- ctx.log.debug(`memory_search raw candidates: ${result.hits.length}`);
483
494
 
484
- const rawCandidates = result.hits.map((h) => ({
495
+ // ── Phase 1: Local search ∥ Hub search (parallel) ──
496
+ const localSearchP = engine.search({ query, maxResults: effectiveMaxResults, minScore, role, ownerFilter });
497
+ const hubSearchP = searchScope !== "local"
498
+ ? hubSearchMemories(store, ctx, { query, maxResults: searchLimit, scope: searchScope as any, hubAddress, userToken })
499
+ .catch(() => ({ hits: [] as any[], meta: { totalCandidates: 0, searchedGroups: [] as string[], includedPublic: searchScope === "all" } }))
500
+ : Promise.resolve(null);
501
+
502
+ const [result, hubResult] = await Promise.all([localSearchP, hubSearchP]);
503
+ ctx.log.debug(`memory_search raw candidates: local=${result.hits.length}, hub=${hubResult?.hits?.length ?? 0}`);
504
+
505
+ // Split local results: pure-local vs hub-memory (Hub role's hub_memories mixed in by RecallEngine)
506
+ const localHits = result.hits.filter((h) => h.origin !== "hub-memory");
507
+ const hubLocalHits = result.hits.filter((h) => h.origin === "hub-memory");
508
+
509
+ const rawLocalCandidates = localHits.map((h) => ({
485
510
  chunkId: h.ref.chunkId,
486
511
  role: h.source.role,
487
512
  score: h.score,
@@ -490,208 +515,156 @@ const memosLocalPlugin = {
490
515
  origin: h.origin || "local",
491
516
  }));
492
517
 
493
- if (result.hits.length === 0 && searchScope === "local") {
518
+ // Hub remote candidates (from HTTP call) + hub-memory candidates (from RecallEngine for Hub role)
519
+ const hubRemoteHits = hubResult?.hits ?? [];
520
+ const rawHubCandidates = [
521
+ ...hubLocalHits.map((h) => ({
522
+ score: h.score,
523
+ role: h.source.role,
524
+ summary: h.summary,
525
+ original_excerpt: (h.original_excerpt ?? "").slice(0, 200),
526
+ origin: "hub-memory" as const,
527
+ ownerName: "",
528
+ groupName: "",
529
+ })),
530
+ ...hubRemoteHits.map((h: any) => ({
531
+ score: h.score ?? 0,
532
+ role: h.source?.role ?? h.role ?? "assistant",
533
+ summary: h.summary ?? "",
534
+ original_excerpt: (h.excerpt ?? h.summary ?? "").slice(0, 200),
535
+ origin: "hub-remote" as const,
536
+ ownerName: h.ownerName ?? "",
537
+ groupName: h.groupName ?? "",
538
+ })),
539
+ ];
540
+
541
+ if (localHits.length === 0 && rawHubCandidates.length === 0) {
494
542
  return {
495
543
  content: [{ type: "text", text: result.meta.note ?? "No relevant memories found." }],
496
- details: { candidates: [], meta: result.meta },
544
+ details: { candidates: rawLocalCandidates, hubCandidates: [], filtered: [], meta: result.meta },
497
545
  };
498
546
  }
499
547
 
500
- let filteredHits = result.hits;
501
- let sufficient = false;
502
-
503
- const candidates = result.hits.map((h, i) => ({
504
- index: i + 1,
505
- role: h.source.role,
506
- content: (h.original_excerpt ?? "").slice(0, 300),
507
- time: h.source.ts ? new Date(h.source.ts).toISOString().slice(0, 16) : "",
508
- }));
509
-
510
- const filterResult = await summarizer.filterRelevant(query, candidates);
511
- if (filterResult !== null) {
512
- sufficient = filterResult.sufficient;
513
- if (filterResult.relevant.length > 0) {
514
- const indexSet = new Set(filterResult.relevant);
515
- filteredHits = result.hits.filter((_, i) => indexSet.has(i + 1));
516
- ctx.log.debug(`memory_search LLM filter: ${result.hits.length} → ${filteredHits.length} hits, sufficient=${sufficient}`);
517
- } else if (searchScope === "local") {
518
- return {
519
- content: [{ type: "text", text: "No relevant memories found for this query." }],
520
- details: { candidates: rawCandidates, filtered: [], meta: result.meta },
521
- };
522
- } else {
523
- filteredHits = [];
524
- }
525
- }
526
-
527
- const beforeDedup = filteredHits.length;
528
- filteredHits = deduplicateHits(filteredHits);
529
- ctx.log.debug(`memory_search dedup: ${beforeDedup} → ${filteredHits.length}`);
530
-
531
- const localDetailsHits = filteredHits.map((h) => {
532
- let effectiveTaskId = h.taskId;
533
- if (effectiveTaskId) {
534
- const t = store.getTask(effectiveTaskId);
535
- if (t && t.status === "skipped") effectiveTaskId = null;
536
- }
537
- return {
538
- ref: h.ref,
539
- chunkId: h.ref.chunkId,
540
- taskId: effectiveTaskId,
541
- skillId: h.skillId,
548
+ // ── Phase 2: Merge all candidates → single LLM filter ──
549
+ const allHitsForFilter = [...localHits, ...hubLocalHits];
550
+ const hubRemoteForFilter = hubRemoteHits;
551
+ const mergedCandidates = [
552
+ ...allHitsForFilter.map((h, i) => ({
553
+ index: i + 1,
542
554
  role: h.source.role,
543
- score: h.score,
544
- summary: h.summary,
545
- origin: h.origin || "local",
546
- };
547
- });
555
+ content: (h.original_excerpt ?? "").slice(0, 300),
556
+ time: h.source.ts ? new Date(h.source.ts).toISOString().slice(0, 16) : "",
557
+ })),
558
+ ...hubRemoteForFilter.map((h: any, i: number) => ({
559
+ index: allHitsForFilter.length + i + 1,
560
+ role: (h.source?.role || "assistant") as string,
561
+ content: (h.summary || h.excerpt || "").slice(0, 300),
562
+ time: h.source?.ts ? new Date(h.source.ts).toISOString().slice(0, 16) : "",
563
+ })),
564
+ ];
565
+
566
+ let filteredLocalHits = allHitsForFilter;
567
+ let filteredHubRemoteHits = hubRemoteForFilter;
568
+ let sufficient = false;
548
569
 
549
- if (searchScope !== "local") {
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}`);
570
+ if (mergedCandidates.length > 0) {
571
+ const filterResult = await summarizer.filterRelevant(query, mergedCandidates);
572
+ if (filterResult !== null) {
573
+ sufficient = filterResult.sufficient;
574
+ if (filterResult.relevant.length > 0) {
575
+ const relevantSet = new Set(filterResult.relevant);
576
+ const hubStartIdx = allHitsForFilter.length + 1;
577
+ filteredLocalHits = allHitsForFilter.filter((_, i) => relevantSet.has(i + 1));
578
+ filteredHubRemoteHits = hubRemoteForFilter.filter((_: any, i: number) => relevantSet.has(hubStartIdx + i));
579
+ ctx.log.debug(`memory_search LLM filter: merged ${mergedCandidates.length} local ${filteredLocalHits.length}, hub ${filteredHubRemoteHits.length}`);
580
+ } else {
581
+ filteredLocalHits = [];
582
+ filteredHubRemoteHits = [];
574
583
  }
575
584
  }
576
-
577
- const originLabel = (h: SearchHit) => {
578
- if (h.origin === "hub-memory") return " [团队缓存]";
579
- if (h.origin === "local-shared") return " [本机共享]";
580
- return "";
581
- };
582
- const localText = filteredHits.length > 0
583
- ? filteredHits.map((h, i) => {
584
- const excerpt = h.original_excerpt.length > 220 ? h.original_excerpt.slice(0, 217) + "..." : h.original_excerpt;
585
- return `${i + 1}. [${h.source.role}]${originLabel(h)} ${excerpt}`;
586
- }).join("\n")
587
- : "(none)";
588
- const hubText = filteredHubHits.length > 0
589
- ? filteredHubHits.map((h, i) => `${i + 1}. [${h.ownerName}] [团队] ${h.summary}${h.groupName ? ` (${h.groupName})` : ""}`).join("\n")
590
- : "(none)";
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
-
610
- return {
611
- content: [{
612
- type: "text",
613
- text: `Local results:\n${localText}\n\nHub results:\n${hubText}`,
614
- }],
615
- details: {
616
- local: { hits: localDetailsFiltered, meta: result.meta },
617
- hub: { ...hub, hits: filteredHubHits },
618
- },
619
- };
620
585
  }
621
586
 
622
- if (filteredHits.length === 0) {
587
+ const beforeDedup = filteredLocalHits.length;
588
+ filteredLocalHits = deduplicateHits(filteredLocalHits);
589
+ ctx.log.debug(`memory_search dedup: ${beforeDedup} → ${filteredLocalHits.length}`);
590
+
591
+ if (filteredLocalHits.length === 0 && filteredHubRemoteHits.length === 0) {
623
592
  return {
624
593
  content: [{ type: "text", text: "No relevant memories found for this query." }],
625
- details: { candidates: rawCandidates, filtered: [], meta: result.meta },
594
+ details: { candidates: rawLocalCandidates, hubCandidates: rawHubCandidates, filtered: [], meta: result.meta },
626
595
  };
627
596
  }
628
597
 
598
+ // ── Phase 3: Build response text ──
629
599
  const originTag = (o?: string) => {
630
600
  if (o === "local-shared") return " [本机共享]";
631
601
  if (o === "hub-memory") return " [团队缓存]";
632
602
  if (o === "hub-remote") return " [团队]";
633
603
  return "";
634
604
  };
635
- const lines = filteredHits.map((h, i) => {
636
- const excerpt = h.original_excerpt;
637
- const parts = [`${i + 1}. [${h.source.role}]${originTag(h.origin)}`];
638
- if (excerpt) parts.push(` ${excerpt}`);
605
+
606
+ const localLines = filteredLocalHits.map((h, i) => {
607
+ const excerpt = h.original_excerpt.length > 220 ? h.original_excerpt.slice(0, 217) + "..." : h.original_excerpt;
608
+ const parts = [`${i + 1}. [${h.source.role}]${originTag(h.origin)} ${excerpt}`];
639
609
  parts.push(` chunkId="${h.ref.chunkId}"`);
640
610
  if (h.taskId) {
641
611
  const task = store.getTask(h.taskId);
642
- if (task && task.status !== "skipped") {
643
- parts.push(` task_id="${h.taskId}"`);
644
- }
612
+ if (task && task.status !== "skipped") parts.push(` task_id="${h.taskId}"`);
645
613
  }
646
614
  return parts.join("\n");
647
615
  });
648
616
 
617
+ const hubLines = filteredHubRemoteHits.map((h: any, i: number) =>
618
+ `${i + 1}. [${h.ownerName ?? "team"}] [团队] ${h.summary ?? ""}${h.groupName ? ` (${h.groupName})` : ""}`
619
+ );
620
+
649
621
  let tipsText = "";
650
622
  if (!sufficient) {
651
- const hasTask = filteredHits.some((h) => {
623
+ const hasTask = filteredLocalHits.some((h) => {
652
624
  if (!h.taskId) return false;
653
625
  const t = store.getTask(h.taskId);
654
626
  return t && t.status !== "skipped";
655
627
  });
656
-
657
628
  const tips: string[] = [];
658
629
  if (hasTask) {
659
630
  tips.push("→ call task_summary(taskId) for full task context");
660
631
  tips.push("→ call skill_get(taskId=...) if the task has a proven experience guide");
661
632
  }
662
633
  tips.push("→ call memory_timeline(chunkId) to expand surrounding conversation");
663
-
664
- if (tips.length > 0) {
665
- tipsText = "\n\nThese memories may not be enough. You can fetch more context:\n" + tips.join("\n");
666
- }
634
+ if (tips.length > 0) tipsText = "\n\nThese memories may not be enough. You can fetch more context:\n" + tips.join("\n");
667
635
  }
668
636
 
637
+ const localText = localLines.length > 0 ? localLines.join("\n\n") : "(none)";
638
+ const hubText = hubLines.length > 0 ? hubLines.join("\n") : "(none)";
639
+ const totalFiltered = filteredLocalHits.length + filteredHubRemoteHits.length;
640
+ const responseText = filteredHubRemoteHits.length > 0
641
+ ? `Found ${totalFiltered} relevant memories:\n\nLocal results:\n${localText}\n\nHub results:\n${hubText}${tipsText}`
642
+ : `Found ${totalFiltered} relevant memories:\n\n${localText}${tipsText}`;
643
+
644
+ const filteredDetails = [
645
+ ...filteredLocalHits.map((h) => {
646
+ let effectiveTaskId = h.taskId;
647
+ if (effectiveTaskId) { const t = store.getTask(effectiveTaskId); if (t && t.status === "skipped") effectiveTaskId = null; }
648
+ return {
649
+ chunkId: h.ref.chunkId, taskId: effectiveTaskId, skillId: h.skillId,
650
+ role: h.source.role, score: h.score, summary: h.summary,
651
+ original_excerpt: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "local",
652
+ };
653
+ }),
654
+ ...filteredHubRemoteHits.map((h: any) => ({
655
+ chunkId: "", taskId: null, skillId: null,
656
+ role: h.source?.role ?? h.role ?? "assistant", score: h.score ?? 0,
657
+ summary: h.summary ?? "", original_excerpt: (h.excerpt ?? h.summary ?? "").slice(0, 200),
658
+ origin: "hub-remote", ownerName: h.ownerName ?? "", groupName: h.groupName ?? "",
659
+ })),
660
+ ];
661
+
669
662
  return {
670
- content: [
671
- {
672
- type: "text",
673
- text: `Found ${filteredHits.length} relevant memories:\n\n${lines.join("\n\n")}${tipsText}`,
674
- },
675
- ],
663
+ content: [{ type: "text", text: responseText }],
676
664
  details: {
677
- candidates: rawCandidates,
678
- hits: filteredHits.map((h) => {
679
- let effectiveTaskId = h.taskId;
680
- if (effectiveTaskId) {
681
- const t = store.getTask(effectiveTaskId);
682
- if (t && t.status === "skipped") effectiveTaskId = null;
683
- }
684
- return {
685
- chunkId: h.ref.chunkId,
686
- taskId: effectiveTaskId,
687
- skillId: h.skillId,
688
- role: h.source.role,
689
- score: h.score,
690
- summary: h.summary,
691
- original_excerpt: (h.original_excerpt ?? "").slice(0, 200),
692
- origin: h.origin || "local",
693
- };
694
- }),
665
+ candidates: rawLocalCandidates,
666
+ hubCandidates: rawHubCandidates,
667
+ filtered: filteredDetails,
695
668
  meta: result.meta,
696
669
  },
697
670
  };
@@ -713,14 +686,15 @@ const memosLocalPlugin = {
713
686
  chunkId: Type.String({ description: "The chunkId from a memory_search hit" }),
714
687
  window: Type.Optional(Type.Number({ description: "Context window ±N (default 2)" })),
715
688
  }),
716
- execute: trackTool("memory_timeline", async (_toolCallId: any, params: any) => {
717
- ctx.log.debug(`memory_timeline called (agent=${currentAgentId})`);
689
+ execute: trackTool("memory_timeline", async (_toolCallId: any, params: any, context?: any) => {
690
+ const agentId = context?.agentId ?? currentAgentId;
691
+ ctx.log.debug(`memory_timeline called (agent=${agentId})`);
718
692
  const { chunkId, window: win } = params as {
719
693
  chunkId: string;
720
694
  window?: number;
721
695
  };
722
696
 
723
- const ownerFilter = [`agent:${currentAgentId}`, "public"];
697
+ const ownerFilter = [`agent:${agentId}`, "public"];
724
698
  const anchorChunk = store.getChunkForOwners(chunkId, ownerFilter);
725
699
  if (!anchorChunk) {
726
700
  return {
@@ -778,7 +752,8 @@ const memosLocalPlugin = {
778
752
  const { chunkId, maxChars } = params as { chunkId: string; maxChars?: number };
779
753
  const limit = Math.min(maxChars ?? DEFAULTS.getMaxCharsDefault, DEFAULTS.getMaxCharsMax);
780
754
 
781
- const ownerFilter = [`agent:${currentAgentId}`, "public"];
755
+ const agentId = context?.agentId ?? currentAgentId;
756
+ const ownerFilter = [`agent:${agentId}`, "public"];
782
757
  const chunk = store.getChunkForOwners(chunkId, ownerFilter);
783
758
  if (!chunk) {
784
759
  return {
@@ -952,7 +927,8 @@ const memosLocalPlugin = {
952
927
  }),
953
928
  }) as any;
954
929
 
955
- store.markTaskShared(task.id, hubTaskId, chunks.length, visibility, groupId);
930
+ const conn = store.getClientHubConnection();
931
+ store.markTaskShared(task.id, hubTaskId, chunks.length, visibility, groupId, conn?.hubInstanceId ?? "");
956
932
 
957
933
  return {
958
934
  content: [{ type: "text", text: `Shared task "${task.title}" with ${chunks.length} chunks to the hub.` }],
@@ -1609,16 +1585,32 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1609
1585
  };
1610
1586
  }
1611
1587
 
1612
- const localText = localHits.length > 0
1613
- ? localHits.map((h, i) => `${i + 1}. [${h.name}] ${h.description.slice(0, 150)}${h.visibility === "public" ? " (shared to local agents)" : ""}`).join("\n")
1588
+ let filteredLocal = localHits;
1589
+ let filteredHub = hub.hits;
1590
+ if (localHits.length > 0 && hub.hits.length > 0) {
1591
+ const allCandidates = [
1592
+ ...localHits.map((h, i) => ({ index: i + 1, role: "skill" as const, content: `[${h.name}] ${h.description.slice(0, 200)}` })),
1593
+ ...hub.hits.map((h, i) => ({ index: localHits.length + i + 1, role: "skill" as const, content: `[${h.name}] ${h.description.slice(0, 200)}` })),
1594
+ ];
1595
+ const mergedFilter = await summarizer.filterRelevant(skillQuery, allCandidates);
1596
+ if (mergedFilter !== null && mergedFilter.relevant.length > 0) {
1597
+ const relevantSet = new Set(mergedFilter.relevant);
1598
+ filteredLocal = localHits.filter((_, i) => relevantSet.has(i + 1));
1599
+ filteredHub = hub.hits.filter((_, i) => relevantSet.has(localHits.length + i + 1));
1600
+ ctx.log.debug(`skill_search LLM filter (merged): local ${localHits.length}→${filteredLocal.length}, hub ${hub.hits.length}→${filteredHub.length}`);
1601
+ }
1602
+ }
1603
+
1604
+ const localText = filteredLocal.length > 0
1605
+ ? filteredLocal.map((h, i) => `${i + 1}. [${h.name}] ${h.description.slice(0, 150)}${h.visibility === "public" ? " (shared to local agents)" : ""}`).join("\n")
1614
1606
  : "(none)";
1615
- const hubText = hub.hits.length > 0
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")
1607
+ const hubText = filteredHub.length > 0
1608
+ ? filteredHub.map((h, i) => `${i + 1}. [${h.name}] ${h.description.slice(0, 150)} (${h.visibility}${h.groupName ? `:${h.groupName}` : ""}, owner=${h.ownerName})`).join("\n")
1617
1609
  : "(none)";
1618
1610
 
1619
1611
  return {
1620
1612
  content: [{ type: "text", text: `Local skills:\n${localText}\n\nHub skills:\n${hubText}` }],
1621
- details: { query: skillQuery, scope: rawScope, local: { hits: localHits }, hub },
1613
+ details: { query: skillQuery, scope: rawScope, local: { hits: filteredLocal }, hub: { hits: filteredHub } },
1622
1614
  };
1623
1615
  }
1624
1616
 
@@ -1781,7 +1773,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1781
1773
 
1782
1774
  // ─── Auto-recall: inject relevant memories before agent starts ───
1783
1775
 
1784
- api.on("before_agent_start", async (event: { prompt?: string; messages?: unknown[] }, hookCtx?: { agentId?: string; sessionKey?: string }) => {
1776
+ api.on("before_prompt_build", async (event: { prompt?: string; messages?: unknown[] }, hookCtx?: { agentId?: string; sessionKey?: string }) => {
1785
1777
  if (!allowPromptInjection) return {};
1786
1778
  if (!event.prompt || event.prompt.length < 3) return;
1787
1779
 
@@ -1823,46 +1815,53 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1823
1815
  }
1824
1816
  ctx.log.debug(`auto-recall: query="${query.slice(0, 80)}"`);
1825
1817
 
1826
- const result = await engine.search({ query, maxResults: 10, minScore: 0.45, ownerFilter: recallOwnerFilter });
1818
+ // ── Phase 1: Local search Hub search (parallel) ──
1819
+ const arLocalP = engine.search({ query, maxResults: 10, minScore: 0.45, ownerFilter: recallOwnerFilter });
1820
+ const arHubP = ctx.config?.sharing?.enabled
1821
+ ? hubSearchMemories(store, ctx, { query, maxResults: 10, scope: "all" })
1822
+ .catch((err: any) => { ctx.log.debug(`auto-recall: hub search failed (${err})`); return { hits: [] as any[], meta: {} }; })
1823
+ : Promise.resolve({ hits: [] as any[], meta: {} });
1824
+
1825
+ const [result, arHubResult] = await Promise.all([arLocalP, arHubP]);
1826
+
1827
+ const localHits = result.hits.filter((h) => h.origin !== "hub-memory");
1828
+ const hubLocalHits = result.hits.filter((h) => h.origin === "hub-memory");
1829
+ const hubRemoteHits: SearchHit[] = (arHubResult.hits ?? []).map((h: any) => ({
1830
+ summary: h.summary,
1831
+ original_excerpt: h.excerpt || h.summary,
1832
+ ref: { sessionKey: "", chunkId: h.remoteHitId ?? "", turnId: "", seq: 0 },
1833
+ score: 0.9,
1834
+ taskId: null,
1835
+ skillId: null,
1836
+ origin: "hub-remote" as const,
1837
+ source: { ts: h.source?.ts, role: h.source?.role ?? "assistant", sessionKey: "" },
1838
+ ownerName: h.ownerName,
1839
+ groupName: h.groupName,
1840
+ }));
1841
+ const allHubHits = [...hubLocalHits, ...hubRemoteHits];
1827
1842
 
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
- };
1843
+ ctx.log.debug(`auto-recall: local=${localHits.length}, hub-memory=${hubLocalHits.length}, hub-remote=${hubRemoteHits.length}`);
1850
1844
 
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
- }
1859
- if (result.hits.length === 0) {
1845
+ const rawLocalCandidates = localHits.map((h) => ({
1846
+ score: h.score, role: h.source.role, summary: h.summary,
1847
+ content: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "local",
1848
+ }));
1849
+ const rawHubCandidates = allHubHits.map((h) => ({
1850
+ score: h.score, role: h.source.role, summary: h.summary,
1851
+ content: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "hub-remote",
1852
+ ownerName: (h as any).ownerName ?? "", groupName: (h as any).groupName ?? "",
1853
+ }));
1854
+
1855
+ const allRawHits = [...localHits, ...allHubHits];
1856
+
1857
+ if (allRawHits.length === 0) {
1860
1858
  ctx.log.debug("auto-recall: no memory candidates found");
1861
1859
  const dur = performance.now() - recallT0;
1862
1860
  store.recordToolCall("memory_search", dur, true);
1863
- store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({ candidates: [], filtered: [] }), dur, true);
1861
+ store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({
1862
+ candidates: rawLocalCandidates, hubCandidates: rawHubCandidates, filtered: [],
1863
+ }), dur, true);
1864
1864
 
1865
- // Even without memory hits, try skill recall
1866
1865
  const skillAutoRecallEarly = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall;
1867
1866
  if (skillAutoRecallEarly) {
1868
1867
  try {
@@ -1902,59 +1901,44 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
1902
1901
  return;
1903
1902
  }
1904
1903
 
1905
- const candidates = result.hits.map((h, i) => ({
1904
+ // ── Phase 2: Merge all → single LLM filter ──
1905
+ const mergedForFilter = allRawHits.map((h, i) => ({
1906
1906
  index: i + 1,
1907
1907
  role: h.source.role,
1908
1908
  content: (h.original_excerpt ?? "").slice(0, 300),
1909
1909
  time: h.source.ts ? new Date(h.source.ts).toISOString().slice(0, 16) : "",
1910
1910
  }));
1911
1911
 
1912
- let filteredHits = result.hits;
1912
+ let filteredHits = allRawHits;
1913
1913
  let sufficient = false;
1914
1914
 
1915
- const filterResult = await summarizer.filterRelevant(query, candidates);
1915
+ const filterResult = await summarizer.filterRelevant(query, mergedForFilter);
1916
1916
  if (filterResult !== null) {
1917
1917
  sufficient = filterResult.sufficient;
1918
1918
  if (filterResult.relevant.length > 0) {
1919
1919
  const indexSet = new Set(filterResult.relevant);
1920
- filteredHits = result.hits.filter((_, i) => indexSet.has(i + 1));
1920
+ filteredHits = allRawHits.filter((_, i) => indexSet.has(i + 1));
1921
1921
  } else {
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;
1922
+ const dur = performance.now() - recallT0;
1923
+ store.recordToolCall("memory_search", dur, true);
1924
+ store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({
1925
+ candidates: rawLocalCandidates, hubCandidates: rawHubCandidates, filtered: [],
1926
+ }), dur, true);
1927
+ if (query.length > 50) {
1928
+ const noRecallHint =
1929
+ "## Memory system — ACTION REQUIRED\n\n" +
1930
+ "Auto-recall found no relevant results for a long query. " +
1931
+ "You MUST call `memory_search` now with a shortened query (2-5 key words) before answering. " +
1932
+ "Do NOT skip this step. Do NOT answer without searching first.";
1933
+ return { prependContext: noRecallHint };
1943
1934
  }
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);
1935
+ return;
1952
1936
  }
1953
1937
  }
1954
1938
 
1955
1939
  const beforeDedup = filteredHits.length;
1956
1940
  filteredHits = deduplicateHits(filteredHits);
1957
- ctx.log.debug(`auto-recall: ${result.hits.length} → ${beforeDedup} relevant → ${filteredHits.length} after dedup, sufficient=${sufficient}`);
1941
+ ctx.log.debug(`auto-recall: merged ${allRawHits.length} → ${beforeDedup} relevant → ${filteredHits.length} after dedup, sufficient=${sufficient}`);
1958
1942
 
1959
1943
  const lines = filteredHits.map((h, i) => {
1960
1944
  const excerpt = h.original_excerpt;
@@ -2068,8 +2052,9 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
2068
2052
  const recallDur = performance.now() - recallT0;
2069
2053
  store.recordToolCall("memory_search", recallDur, true);
2070
2054
  store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({
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" }))
2055
+ candidates: rawLocalCandidates,
2056
+ hubCandidates: rawHubCandidates,
2057
+ filtered: filteredHits.map(h => ({ score: h.score, role: h.source.role, summary: h.summary, content: h.original_excerpt, origin: h.origin || "local" })),
2073
2058
  }), recallDur, true);
2074
2059
  telemetry.trackAutoRecall(filteredHits.length, recallDur);
2075
2060
 
@@ -2245,6 +2230,10 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
2245
2230
  const shared = store.listLocalSharedTasks();
2246
2231
  if (shared.length === 0) return;
2247
2232
 
2233
+ // Only sync tasks that have a hub_task_id (actively shared to remote)
2234
+ const conn = store.getClientHubConnection();
2235
+ const currentHubInstanceId = conn?.hubInstanceId || "";
2236
+
2248
2237
  let hubClient: { hubUrl: string; userToken: string; userId: string } | undefined;
2249
2238
  try {
2250
2239
  hubClient = await resolveHubClient(store, ctx);
@@ -2254,6 +2243,8 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
2254
2243
  const { v4: uuidv4 } = require("uuid");
2255
2244
 
2256
2245
  for (const entry of shared) {
2246
+ if (!entry.hubTaskId) continue;
2247
+ if (currentHubInstanceId && entry.hubInstanceId && entry.hubInstanceId !== currentHubInstanceId) continue;
2257
2248
  const task = store.getTask(entry.taskId);
2258
2249
  if (!task) continue;
2259
2250
  const chunks = store.getChunksByTask(entry.taskId);
@@ -2291,7 +2282,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`,
2291
2282
  })),
2292
2283
  }),
2293
2284
  });
2294
- store.markTaskShared(entry.taskId, entry.hubTaskId, chunks.length, entry.visibility, entry.groupId);
2285
+ store.markTaskShared(entry.taskId, entry.hubTaskId, chunks.length, entry.visibility, entry.groupId, currentHubInstanceId);
2295
2286
  } catch (err) {
2296
2287
  ctx.log.warn(`incremental sync failed for task=${entry.taskId}: ${err}`);
2297
2288
  }