@memtensor/memos-local-openclaw-plugin 1.0.4-beta.1 → 1.0.4-beta.11

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 (119) hide show
  1. package/.env.example +7 -0
  2. package/README.md +24 -24
  3. package/dist/capture/index.d.ts +1 -1
  4. package/dist/capture/index.d.ts.map +1 -1
  5. package/dist/capture/index.js +34 -2
  6. package/dist/capture/index.js.map +1 -1
  7. package/dist/client/connector.d.ts +2 -2
  8. package/dist/client/connector.d.ts.map +1 -1
  9. package/dist/client/connector.js +122 -26
  10. package/dist/client/connector.js.map +1 -1
  11. package/dist/client/hub.d.ts.map +1 -1
  12. package/dist/client/hub.js +22 -0
  13. package/dist/client/hub.js.map +1 -1
  14. package/dist/client/skill-sync.d.ts +7 -0
  15. package/dist/client/skill-sync.d.ts.map +1 -1
  16. package/dist/client/skill-sync.js +10 -0
  17. package/dist/client/skill-sync.js.map +1 -1
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +0 -2
  20. package/dist/config.js.map +1 -1
  21. package/dist/hub/server.d.ts +8 -0
  22. package/dist/hub/server.d.ts.map +1 -1
  23. package/dist/hub/server.js +390 -106
  24. package/dist/hub/server.js.map +1 -1
  25. package/dist/hub/user-manager.d.ts +11 -0
  26. package/dist/hub/user-manager.d.ts.map +1 -1
  27. package/dist/hub/user-manager.js +31 -3
  28. package/dist/hub/user-manager.js.map +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +7 -2
  31. package/dist/index.js.map +1 -1
  32. package/dist/ingest/providers/index.d.ts.map +1 -1
  33. package/dist/ingest/providers/index.js +37 -6
  34. package/dist/ingest/providers/index.js.map +1 -1
  35. package/dist/recall/engine.d.ts.map +1 -1
  36. package/dist/recall/engine.js +93 -1
  37. package/dist/recall/engine.js.map +1 -1
  38. package/dist/shared/llm-call.d.ts +1 -0
  39. package/dist/shared/llm-call.d.ts.map +1 -1
  40. package/dist/shared/llm-call.js +82 -8
  41. package/dist/shared/llm-call.js.map +1 -1
  42. package/dist/sharing/types.d.ts +1 -1
  43. package/dist/sharing/types.d.ts.map +1 -1
  44. package/dist/skill/evolver.d.ts +4 -0
  45. package/dist/skill/evolver.d.ts.map +1 -1
  46. package/dist/skill/evolver.js +59 -5
  47. package/dist/skill/evolver.js.map +1 -1
  48. package/dist/skill/generator.d.ts +2 -0
  49. package/dist/skill/generator.d.ts.map +1 -1
  50. package/dist/skill/generator.js +45 -3
  51. package/dist/skill/generator.js.map +1 -1
  52. package/dist/skill/installer.d.ts +26 -0
  53. package/dist/skill/installer.d.ts.map +1 -1
  54. package/dist/skill/installer.js +80 -4
  55. package/dist/skill/installer.js.map +1 -1
  56. package/dist/skill/upgrader.d.ts +2 -0
  57. package/dist/skill/upgrader.d.ts.map +1 -1
  58. package/dist/skill/upgrader.js +139 -1
  59. package/dist/skill/upgrader.js.map +1 -1
  60. package/dist/skill/validator.d.ts +3 -0
  61. package/dist/skill/validator.d.ts.map +1 -1
  62. package/dist/skill/validator.js +75 -0
  63. package/dist/skill/validator.js.map +1 -1
  64. package/dist/storage/ensure-binding.d.ts +12 -0
  65. package/dist/storage/ensure-binding.d.ts.map +1 -0
  66. package/dist/storage/ensure-binding.js +53 -0
  67. package/dist/storage/ensure-binding.js.map +1 -0
  68. package/dist/storage/sqlite.d.ts +89 -20
  69. package/dist/storage/sqlite.d.ts.map +1 -1
  70. package/dist/storage/sqlite.js +374 -124
  71. package/dist/storage/sqlite.js.map +1 -1
  72. package/dist/telemetry.d.ts +12 -5
  73. package/dist/telemetry.d.ts.map +1 -1
  74. package/dist/telemetry.js +156 -40
  75. package/dist/telemetry.js.map +1 -1
  76. package/dist/tools/memory-search.d.ts +3 -1
  77. package/dist/tools/memory-search.d.ts.map +1 -1
  78. package/dist/tools/memory-search.js +3 -1
  79. package/dist/tools/memory-search.js.map +1 -1
  80. package/dist/types.d.ts +11 -2
  81. package/dist/types.d.ts.map +1 -1
  82. package/dist/types.js +4 -0
  83. package/dist/types.js.map +1 -1
  84. package/dist/viewer/html.d.ts.map +1 -1
  85. package/dist/viewer/html.js +2671 -879
  86. package/dist/viewer/html.js.map +1 -1
  87. package/dist/viewer/server.d.ts +30 -8
  88. package/dist/viewer/server.d.ts.map +1 -1
  89. package/dist/viewer/server.js +990 -198
  90. package/dist/viewer/server.js.map +1 -1
  91. package/index.ts +700 -56
  92. package/openclaw.plugin.json +1 -1
  93. package/package.json +3 -2
  94. package/scripts/postinstall.cjs +1 -1
  95. package/skill/memos-memory-guide/SKILL.md +64 -26
  96. package/src/capture/index.ts +37 -1
  97. package/src/client/connector.ts +124 -28
  98. package/src/client/hub.ts +18 -0
  99. package/src/client/skill-sync.ts +14 -0
  100. package/src/config.ts +0 -2
  101. package/src/hub/server.ts +374 -97
  102. package/src/hub/user-manager.ts +48 -8
  103. package/src/index.ts +10 -2
  104. package/src/ingest/providers/index.ts +41 -7
  105. package/src/recall/engine.ts +86 -1
  106. package/src/shared/llm-call.ts +97 -9
  107. package/src/sharing/types.ts +1 -1
  108. package/src/skill/evolver.ts +63 -6
  109. package/src/skill/generator.ts +44 -5
  110. package/src/skill/installer.ts +107 -4
  111. package/src/skill/upgrader.ts +139 -1
  112. package/src/skill/validator.ts +79 -0
  113. package/src/storage/ensure-binding.ts +52 -0
  114. package/src/storage/sqlite.ts +395 -148
  115. package/src/telemetry.ts +172 -41
  116. package/src/tools/memory-search.ts +2 -1
  117. package/src/types.ts +12 -2
  118. package/src/viewer/html.ts +2671 -879
  119. package/src/viewer/server.ts +913 -182
@@ -1,16 +1,27 @@
1
1
  import { randomUUID, createHash } from "crypto";
2
- import { issueUserToken } from "./auth";
2
+ import { issueUserToken, verifyUserToken } from "./auth";
3
3
  import type { Logger } from "../types";
4
4
  import type { UserInfo } from "../sharing/types";
5
5
  import type { SqliteStore } from "../storage/sqlite";
6
6
 
7
- type ManagedHubUser = UserInfo & { tokenHash: string; createdAt: number; approvedAt: number | null };
7
+ type ManagedHubUser = UserInfo & {
8
+ tokenHash: string;
9
+ createdAt: number;
10
+ approvedAt: number | null;
11
+ lastIp: string;
12
+ lastActiveAt: number | null;
13
+ identityKey?: string;
14
+ leftAt?: number | null;
15
+ removedAt?: number | null;
16
+ rejectedAt?: number | null;
17
+ rejoinRequestedAt?: number | null;
18
+ };
8
19
 
9
20
  export class HubUserManager {
10
21
  constructor(private store: SqliteStore, private log: Logger) {}
11
22
 
12
- createPendingUser(input: { username: string; deviceName?: string }): ManagedHubUser {
13
- const user = {
23
+ createPendingUser(input: { username: string; deviceName?: string; identityKey?: string }): ManagedHubUser {
24
+ const user: ManagedHubUser = {
14
25
  id: randomUUID(),
15
26
  username: input.username,
16
27
  deviceName: input.deviceName,
@@ -20,11 +31,38 @@ export class HubUserManager {
20
31
  tokenHash: "",
21
32
  createdAt: Date.now(),
22
33
  approvedAt: null,
34
+ lastIp: "",
35
+ lastActiveAt: null,
36
+ identityKey: input.identityKey || "",
23
37
  };
24
38
  this.store.upsertHubUser(user);
25
39
  return user;
26
40
  }
27
41
 
42
+ findByIdentityKey(identityKey: string): ManagedHubUser | null {
43
+ if (!identityKey) return null;
44
+ return this.store.findHubUserByIdentityKey(identityKey);
45
+ }
46
+
47
+ markUserLeft(userId: string): boolean {
48
+ this.log.info(`Hub: user "${userId}" marked as left`);
49
+ return this.store.markHubUserLeft(userId);
50
+ }
51
+
52
+ rejoinUser(userId: string): ManagedHubUser | null {
53
+ const user = this.store.getHubUser(userId);
54
+ if (!user) return null;
55
+ const updated: ManagedHubUser = {
56
+ ...user,
57
+ status: "pending" as const,
58
+ tokenHash: "",
59
+ rejoinRequestedAt: Date.now(),
60
+ };
61
+ this.store.upsertHubUser(updated);
62
+ this.log.info(`Hub: user "${userId}" (${user.username}) requested rejoin, previous status: ${user.status}`);
63
+ return updated;
64
+ }
65
+
28
66
  listPendingUsers(): ManagedHubUser[] {
29
67
  return this.store.listHubUsers("pending");
30
68
  }
@@ -46,7 +84,7 @@ export class HubUserManager {
46
84
  if (bootstrapUserId) {
47
85
  const bootstrapUser = this.store.getHubUser(bootstrapUserId);
48
86
  if (bootstrapUser && bootstrapUser.role === "admin" && bootstrapUser.status === "active") {
49
- if (bootstrapToken && bootstrapUser.tokenHash === createHash("sha256").update(bootstrapToken).digest("hex")) {
87
+ if (bootstrapToken && bootstrapUser.tokenHash === createHash("sha256").update(bootstrapToken).digest("hex") && verifyUserToken(bootstrapToken, secret)) {
50
88
  return { user: bootstrapUser, token: bootstrapToken };
51
89
  }
52
90
  const refreshedToken = issueUserToken(
@@ -88,6 +126,8 @@ export class HubUserManager {
88
126
  tokenHash: "",
89
127
  createdAt: Date.now(),
90
128
  approvedAt: Date.now(),
129
+ lastIp: "",
130
+ lastActiveAt: null,
91
131
  };
92
132
  const token = issueUserToken(
93
133
  { userId: user.id, username: user.username, role: user.role, status: user.status },
@@ -101,7 +141,7 @@ export class HubUserManager {
101
141
 
102
142
  isUsernameTaken(username: string, excludeUserId?: string): boolean {
103
143
  const users = this.store.listHubUsers();
104
- return users.some(u => u.username === username && u.id !== excludeUserId);
144
+ return users.some(u => u.username === username && u.id !== excludeUserId && u.status !== "left" && u.status !== "removed");
105
145
  }
106
146
 
107
147
  updateUsername(userId: string, newUsername: string): ManagedHubUser | null {
@@ -115,10 +155,10 @@ export class HubUserManager {
115
155
  rejectUser(userId: string): ManagedHubUser | null {
116
156
  const user = this.store.getHubUser(userId);
117
157
  if (!user) return null;
118
- const updated = {
158
+ const updated: ManagedHubUser = {
119
159
  ...user,
120
160
  status: "rejected" as const,
121
- approvedAt: Date.now(),
161
+ rejectedAt: Date.now(),
122
162
  };
123
163
  this.store.upsertHubUser(updated);
124
164
  return updated;
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { v4 as uuid } from "uuid";
2
2
  import { buildContext } from "./config";
3
+ import { ensureSqliteBinding } from "./storage/ensure-binding";
3
4
  import { SqliteStore } from "./storage/sqlite";
4
5
  import { Embedder } from "./embedding";
5
6
  import { IngestWorker } from "./ingest/worker";
@@ -56,13 +57,17 @@ export function initPlugin(opts: PluginInitOptions = {}): MemosLocalPlugin {
56
57
 
57
58
  ctx.log.info("Initializing memos-local plugin...");
58
59
 
60
+ ensureSqliteBinding(ctx.log);
61
+
59
62
  const store = new SqliteStore(ctx.config.storage!.dbPath!, ctx.log);
60
63
  const embedder = new Embedder(ctx.config.embedding, ctx.log, ctx.openclawAPI);
61
64
  const worker = new IngestWorker(store, embedder, ctx);
62
65
  const engine = new RecallEngine(store, embedder, ctx);
63
66
 
67
+ const sharedState = { lastSearchTime: 0 };
68
+
64
69
  const tools: ToolDefinition[] = [
65
- createMemorySearchTool(engine, store, ctx),
70
+ createMemorySearchTool(engine, store, ctx, sharedState),
66
71
  createMemoryTimelineTool(store),
67
72
  createMemoryGetTool(store),
68
73
  createNetworkMemoryDetailTool(store, ctx),
@@ -84,7 +89,10 @@ export function initPlugin(opts: PluginInitOptions = {}): MemosLocalPlugin {
84
89
  const turnId = uuid();
85
90
  const tag = ctx.config.capture?.evidenceWrapperTag ?? "STORED_MEMORY";
86
91
 
87
- const captured = captureMessages(messages, session, turnId, tag, ctx.log, owner);
92
+ const userSearchTime = sharedState.lastSearchTime || 0;
93
+ sharedState.lastSearchTime = 0;
94
+
95
+ const captured = captureMessages(messages, session, turnId, tag, ctx.log, owner, userSearchTime);
88
96
  if (captured.length > 0) {
89
97
  worker.enqueue(captured);
90
98
  }
@@ -1,6 +1,6 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import type { SummarizerConfig, Logger, OpenClawAPI } from "../../types";
3
+ import type { SummarizerConfig, SummaryProvider, Logger, OpenClawAPI } from "../../types";
4
4
  import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, filterRelevantOpenAI, judgeDedupOpenAI, parseFilterResult, parseDedupResult } from "./openai";
5
5
  import type { FilterResult, DedupResult } from "./openai";
6
6
  export type { FilterResult, DedupResult } from "./openai";
@@ -8,6 +8,40 @@ import { summarizeAnthropic, summarizeTaskAnthropic, generateTaskTitleAnthropic,
8
8
  import { summarizeGemini, summarizeTaskGemini, generateTaskTitleGemini, judgeNewTopicGemini, filterRelevantGemini, judgeDedupGemini } from "./gemini";
9
9
  import { summarizeBedrock, summarizeTaskBedrock, generateTaskTitleBedrock, judgeNewTopicBedrock, filterRelevantBedrock, judgeDedupBedrock } from "./bedrock";
10
10
 
11
+ /**
12
+ * Detect provider type from provider key name or base URL.
13
+ */
14
+ function detectProvider(
15
+ providerKey: string | undefined,
16
+ baseUrl: string,
17
+ ): SummaryProvider {
18
+ const key = providerKey?.toLowerCase() ?? "";
19
+ const url = baseUrl.toLowerCase();
20
+ if (key.includes("anthropic") || url.includes("anthropic")) return "anthropic";
21
+ if (key.includes("gemini") || url.includes("generativelanguage.googleapis.com")) {
22
+ return "gemini";
23
+ }
24
+ if (key.includes("bedrock") || url.includes("bedrock")) return "bedrock";
25
+ return "openai_compatible";
26
+ }
27
+
28
+ /**
29
+ * Return the correct endpoint for a given provider and base URL.
30
+ */
31
+ function normalizeEndpointForProvider(
32
+ provider: SummaryProvider,
33
+ baseUrl: string,
34
+ ): string {
35
+ const stripped = baseUrl.replace(/\/+$/, "");
36
+ if (provider === "anthropic") {
37
+ if (stripped.endsWith("/v1/messages")) return stripped;
38
+ return `${stripped}/v1/messages`;
39
+ }
40
+ if (stripped.endsWith("/chat/completions")) return stripped;
41
+ if (stripped.endsWith("/completions")) return stripped;
42
+ return `${stripped}/chat/completions`;
43
+ }
44
+
11
45
  /**
12
46
  * Build a SummarizerConfig from OpenClaw's native model configuration (openclaw.json).
13
47
  * This serves as the final fallback when both strongCfg and plugin summarizer fail or are absent.
@@ -15,7 +49,8 @@ import { summarizeBedrock, summarizeTaskBedrock, generateTaskTitleBedrock, judge
15
49
  function loadOpenClawFallbackConfig(log: Logger): SummarizerConfig | undefined {
16
50
  try {
17
51
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
18
- const cfgPath = path.join(home, ".openclaw", "openclaw.json");
52
+ const ocHome = process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
53
+ const cfgPath = path.join(ocHome, "openclaw.json");
19
54
  if (!fs.existsSync(cfgPath)) return undefined;
20
55
 
21
56
  const raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
@@ -36,13 +71,12 @@ function loadOpenClawFallbackConfig(log: Logger): SummarizerConfig | undefined {
36
71
  const apiKey: string | undefined = providerCfg.apiKey;
37
72
  if (!baseUrl || !apiKey) return undefined;
38
73
 
39
- const endpoint = baseUrl.endsWith("/chat/completions")
40
- ? baseUrl
41
- : baseUrl.replace(/\/+$/, "") + "/chat/completions";
74
+ const provider = detectProvider(providerKey, baseUrl);
75
+ const endpoint = normalizeEndpointForProvider(provider, baseUrl);
42
76
 
43
- log.debug(`OpenClaw fallback model: ${modelId} via ${baseUrl}`);
77
+ log.debug(`OpenClaw fallback model: ${modelId} via ${baseUrl} (${provider})`);
44
78
  return {
45
- provider: "openai_compatible",
79
+ provider,
46
80
  endpoint,
47
81
  apiKey,
48
82
  model: modelId,
@@ -74,10 +74,60 @@ export class RecallEngine {
74
74
  score: 1 / (i + 1),
75
75
  }));
76
76
 
77
+ // Step 1c: Hub memories search (when sharing is enabled and hub_memories exist)
78
+ let hubMemFtsRanked: Array<{ id: string; score: number }> = [];
79
+ let hubMemVecRanked: Array<{ id: string; score: number }> = [];
80
+ let hubMemPatternRanked: Array<{ id: string; score: number }> = [];
81
+ if (query && this.ctx.config.sharing?.enabled) {
82
+ try {
83
+ const hubFtsHits = this.store.searchHubMemories(query, { maxResults: candidatePool });
84
+ hubMemFtsRanked = hubFtsHits.map(({ hit }, i) => ({
85
+ id: `hubmem:${hit.id}`, score: 1 / (i + 1),
86
+ }));
87
+ } catch { /* hub_memories table may not exist */ }
88
+ if (shortTerms.length > 0) {
89
+ try {
90
+ const hubPatternHits = this.store.hubMemoryPatternSearch(shortTerms, { limit: candidatePool });
91
+ hubMemPatternRanked = hubPatternHits.map((h, i) => ({
92
+ id: `hubmem:${h.memoryId}`, score: 1 / (i + 1),
93
+ }));
94
+ } catch { /* best-effort */ }
95
+ }
96
+ try {
97
+ const hubMemEmbs = this.store.getVisibleHubMemoryEmbeddings("");
98
+ if (hubMemEmbs.length > 0) {
99
+ const qv = await this.embedder.embedQuery(query).catch(() => null);
100
+ if (qv) {
101
+ const scored: Array<{ id: string; score: number }> = [];
102
+ for (const e of hubMemEmbs) {
103
+ let dot = 0, nA = 0, nB = 0;
104
+ for (let i = 0; i < qv.length && i < e.vector.length; i++) {
105
+ dot += qv[i] * e.vector[i]; nA += qv[i] * qv[i]; nB += e.vector[i] * e.vector[i];
106
+ }
107
+ const sim = nA > 0 && nB > 0 ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
108
+ if (sim > 0.3) {
109
+ scored.push({ id: `hubmem:${e.memoryId}`, score: sim });
110
+ }
111
+ }
112
+ scored.sort((a, b) => b.score - a.score);
113
+ hubMemVecRanked = scored.slice(0, candidatePool);
114
+ }
115
+ }
116
+ } catch { /* best-effort */ }
117
+ const hubTotal = hubMemFtsRanked.length + hubMemVecRanked.length + hubMemPatternRanked.length;
118
+ if (hubTotal > 0) {
119
+ this.ctx.log.debug(`recall: hub_memories candidates: fts=${hubMemFtsRanked.length}, vec=${hubMemVecRanked.length}, pattern=${hubMemPatternRanked.length}`);
120
+ }
121
+ }
122
+
77
123
  // Step 2: RRF fusion
78
124
  const ftsRanked = ftsCandidates.map((c) => ({ id: c.chunkId, score: c.score }));
79
125
  const vecRanked = vecCandidates.map((c) => ({ id: c.chunkId, score: c.score }));
80
- const rrfScores = rrfFuse([ftsRanked, vecRanked, patternRanked], recallCfg.rrfK);
126
+ const allRankedLists = [ftsRanked, vecRanked, patternRanked];
127
+ if (hubMemFtsRanked.length > 0) allRankedLists.push(hubMemFtsRanked);
128
+ if (hubMemVecRanked.length > 0) allRankedLists.push(hubMemVecRanked);
129
+ if (hubMemPatternRanked.length > 0) allRankedLists.push(hubMemPatternRanked);
130
+ const rrfScores = rrfFuse(allRankedLists, recallCfg.rrfK);
81
131
 
82
132
  if (rrfScores.size === 0) {
83
133
  this.recordQuery(query, maxResults, minScore, 0);
@@ -101,6 +151,11 @@ export class RecallEngine {
101
151
 
102
152
  // Step 4: Time decay
103
153
  const withTs = mmrResults.map((r) => {
154
+ if (r.id.startsWith("hubmem:")) {
155
+ const memId = r.id.slice(7);
156
+ const mem = this.store.getHubMemoryById(memId);
157
+ return { ...r, createdAt: mem?.createdAt ?? 0 };
158
+ }
104
159
  const chunk = this.store.getChunk(r.id);
105
160
  return { ...r, createdAt: chunk?.createdAt ?? 0 };
106
161
  });
@@ -128,6 +183,35 @@ export class RecallEngine {
128
183
  const hits: SearchHit[] = [];
129
184
  for (const candidate of normalized) {
130
185
  if (hits.length >= maxResults) break;
186
+
187
+ if (candidate.id.startsWith("hubmem:")) {
188
+ const memId = candidate.id.slice(7);
189
+ const mem = this.store.getHubMemoryById(memId);
190
+ if (!mem) continue;
191
+ if (roleFilter && mem.role !== roleFilter) continue;
192
+ hits.push({
193
+ summary: mem.summary || mem.content.slice(0, 200),
194
+ original_excerpt: mem.content,
195
+ ref: {
196
+ sessionKey: `hub-shared:${mem.sourceUserId}`,
197
+ chunkId: mem.id,
198
+ turnId: "",
199
+ seq: 0,
200
+ },
201
+ score: Math.round(candidate.score * 1000) / 1000,
202
+ taskId: null,
203
+ skillId: null,
204
+ owner: `hub-user:${mem.sourceUserId}`,
205
+ origin: "hub-memory",
206
+ source: {
207
+ ts: mem.createdAt,
208
+ role: (mem.role || "assistant") as any,
209
+ sessionKey: `hub-shared:${mem.sourceUserId}`,
210
+ },
211
+ });
212
+ continue;
213
+ }
214
+
131
215
  const chunk = this.store.getChunk(candidate.id);
132
216
  if (!chunk) continue;
133
217
  if (roleFilter && chunk.role !== roleFilter) continue;
@@ -145,6 +229,7 @@ export class RecallEngine {
145
229
  score: Math.round(candidate.score * 1000) / 1000,
146
230
  taskId: chunk.taskId,
147
231
  skillId: chunk.skillId,
232
+ origin: chunk.owner === "public" ? "local-shared" : "local",
148
233
  source: {
149
234
  ts: chunk.createdAt,
150
235
  role: chunk.role,
@@ -1,6 +1,34 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import type { SummarizerConfig, Logger, PluginContext, OpenClawAPI } from "../types";
3
+ import type { SummarizerConfig, SummaryProvider, Logger, PluginContext, OpenClawAPI } from "../types";
4
+
5
+ /**
6
+ * Detect provider type from provider key name or base URL.
7
+ */
8
+ function detectProvider(providerKey: string | undefined, baseUrl: string): SummaryProvider {
9
+ const key = providerKey?.toLowerCase() ?? "";
10
+ const url = baseUrl.toLowerCase();
11
+ if (key.includes("anthropic") || url.includes("anthropic")) return "anthropic";
12
+ if (key.includes("gemini") || url.includes("generativelanguage.googleapis.com")) {
13
+ return "gemini";
14
+ }
15
+ if (key.includes("bedrock") || url.includes("bedrock")) return "bedrock";
16
+ return "openai_compatible";
17
+ }
18
+
19
+ /**
20
+ * Return the correct default endpoint for a given provider.
21
+ */
22
+ function defaultEndpointForProvider(provider: SummaryProvider, baseUrl: string): string {
23
+ const stripped = baseUrl.replace(/\/+$/, "");
24
+ if (provider === "anthropic") {
25
+ if (stripped.endsWith("/v1/messages")) return stripped;
26
+ return `${stripped}/v1/messages`;
27
+ }
28
+ if (stripped.endsWith("/chat/completions")) return stripped;
29
+ if (stripped.endsWith("/completions")) return stripped;
30
+ return `${stripped}/chat/completions`;
31
+ }
4
32
 
5
33
  /**
6
34
  * Build a SummarizerConfig from OpenClaw's native model configuration (openclaw.json).
@@ -30,13 +58,12 @@ export function loadOpenClawFallbackConfig(log: Logger): SummarizerConfig | unde
30
58
  const apiKey: string | undefined = providerCfg.apiKey;
31
59
  if (!baseUrl || !apiKey) return undefined;
32
60
 
33
- const endpoint = baseUrl.endsWith("/chat/completions")
34
- ? baseUrl
35
- : baseUrl.replace(/\/+$/, "") + "/chat/completions";
61
+ const provider = detectProvider(providerKey, baseUrl);
62
+ const endpoint = defaultEndpointForProvider(provider, baseUrl);
36
63
 
37
- log.debug(`OpenClaw fallback model: ${modelId} via ${baseUrl}`);
64
+ log.debug(`OpenClaw fallback model: ${modelId} via ${baseUrl} (${provider})`);
38
65
  return {
39
- provider: "openai_compatible",
66
+ provider,
40
67
  endpoint,
41
68
  apiKey,
42
69
  model: modelId,
@@ -70,23 +97,34 @@ export interface LLMCallOptions {
70
97
  openclawAPI?: OpenClawAPI;
71
98
  }
72
99
 
73
- function normalizeEndpoint(url: string): string {
100
+ function normalizeOpenAIEndpoint(url: string): string {
74
101
  const stripped = url.replace(/\/+$/, "");
75
102
  if (stripped.endsWith("/chat/completions")) return stripped;
76
103
  if (stripped.endsWith("/completions")) return stripped;
77
104
  return `${stripped}/chat/completions`;
78
105
  }
79
106
 
107
+ function normalizeAnthropicEndpoint(url: string): string {
108
+ const stripped = url.replace(/\/+$/, "");
109
+ if (stripped.endsWith("/v1/messages")) return stripped;
110
+ if (stripped.endsWith("/messages")) return stripped;
111
+ return `${stripped}/v1/messages`;
112
+ }
113
+
114
+ function isAnthropicProvider(cfg: SummarizerConfig): boolean {
115
+ return cfg.provider === "anthropic";
116
+ }
117
+
80
118
  /**
81
119
  * Make a single LLM call with the given config. Throws on failure.
82
120
  * When cfg.provider === "openclaw", delegates to the OpenClaw host completion API.
121
+ * Dispatches to Anthropic or OpenAI-compatible format based on provider.
83
122
  */
84
123
  export async function callLLMOnce(
85
124
  cfg: SummarizerConfig,
86
125
  prompt: string,
87
126
  opts: LLMCallOptions = {},
88
127
  ): Promise<string> {
89
- // Handle openclaw provider via host completion API
90
128
  if (cfg.provider === "openclaw") {
91
129
  const api = opts.openclawAPI;
92
130
  if (!api) {
@@ -101,7 +139,57 @@ export async function callLLMOnce(
101
139
  return response.text.trim();
102
140
  }
103
141
 
104
- const endpoint = normalizeEndpoint(cfg.endpoint ?? "https://api.openai.com/v1/chat/completions");
142
+ if (isAnthropicProvider(cfg)) {
143
+ return callLLMOnceAnthropic(cfg, prompt, opts);
144
+ }
145
+ return callLLMOnceOpenAI(cfg, prompt, opts);
146
+ }
147
+
148
+ async function callLLMOnceAnthropic(
149
+ cfg: SummarizerConfig,
150
+ prompt: string,
151
+ opts: LLMCallOptions = {},
152
+ ): Promise<string> {
153
+ const endpoint = normalizeAnthropicEndpoint(
154
+ cfg.endpoint ?? "https://api.anthropic.com/v1/messages",
155
+ );
156
+ const model = cfg.model ?? "claude-3-haiku-20240307";
157
+ const headers: Record<string, string> = {
158
+ "Content-Type": "application/json",
159
+ "x-api-key": cfg.apiKey ?? "",
160
+ "anthropic-version": "2023-06-01",
161
+ ...cfg.headers,
162
+ };
163
+
164
+ const resp = await fetch(endpoint, {
165
+ method: "POST",
166
+ headers,
167
+ body: JSON.stringify({
168
+ model,
169
+ temperature: opts.temperature ?? 0.1,
170
+ max_tokens: opts.maxTokens ?? 1024,
171
+ messages: [{ role: "user", content: prompt }],
172
+ }),
173
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 30_000),
174
+ });
175
+
176
+ if (!resp.ok) {
177
+ const body = await resp.text();
178
+ throw new Error(`LLM call failed (${resp.status}): ${body}`);
179
+ }
180
+
181
+ const json = (await resp.json()) as { content: Array<{ type: string; text: string }> };
182
+ return json.content.find((c) => c.type === "text")?.text?.trim() ?? "";
183
+ }
184
+
185
+ async function callLLMOnceOpenAI(
186
+ cfg: SummarizerConfig,
187
+ prompt: string,
188
+ opts: LLMCallOptions = {},
189
+ ): Promise<string> {
190
+ const endpoint = normalizeOpenAIEndpoint(
191
+ cfg.endpoint ?? "https://api.openai.com/v1/chat/completions",
192
+ );
105
193
  const model = cfg.model ?? "gpt-4o-mini";
106
194
  const headers: Record<string, string> = {
107
195
  "Content-Type": "application/json",
@@ -12,7 +12,7 @@ import type {
12
12
  export type HubScope = "local" | "group" | "all";
13
13
  export type SharedVisibility = "group" | "public";
14
14
  export type UserRole = "admin" | "member";
15
- export type UserStatus = "pending" | "active" | "blocked" | "rejected";
15
+ export type UserStatus = "pending" | "active" | "blocked" | "rejected" | "removed" | "left";
16
16
 
17
17
  export type { ClientModeConfig, HubModeConfig, SharingCapabilities, SharingConfig, SharingRole };
18
18
 
@@ -9,9 +9,11 @@ import { DEFAULTS } from "../types";
9
9
  import { SkillEvaluator } from "./evaluator";
10
10
  import { SkillGenerator } from "./generator";
11
11
  import { SkillUpgrader } from "./upgrader";
12
- import { SkillInstaller } from "./installer";
12
+ import { SkillInstaller, type SkillInstallMode } from "./installer";
13
13
  import { buildSkillConfigChain, callLLMWithFallback } from "../shared/llm-call";
14
14
 
15
+ export type SkillEvolvedCallback = (skillName: string, upgradeType: "created" | "upgraded") => void;
16
+
15
17
  export class SkillEvolver {
16
18
  private evaluator: SkillEvaluator;
17
19
  private generator: SkillGenerator;
@@ -19,6 +21,7 @@ export class SkillEvolver {
19
21
  private installer: SkillInstaller;
20
22
  private processing = false;
21
23
  private queue: Task[] = [];
24
+ onSkillEvolved: SkillEvolvedCallback | null = null;
22
25
 
23
26
  constructor(
24
27
  private store: SqliteStore,
@@ -93,10 +96,19 @@ export class SkillEvolver {
93
96
  return;
94
97
  }
95
98
 
99
+ const preferUpgrade = this.ctx.config.skillEvolution?.preferUpgradeExisting ?? DEFAULTS.skillPreferUpgrade;
96
100
  const relatedSkill = await this.findRelatedSkill(task);
97
101
 
98
102
  if (relatedSkill) {
99
103
  await this.handleExistingSkill(task, chunks, relatedSkill);
104
+ } else if (preferUpgrade) {
105
+ const nameCandidate = await this.findSkillByNameSimilarity(task);
106
+ if (nameCandidate) {
107
+ this.ctx.log.info(`SkillEvolver: preferUpgrade found name-similar skill "${nameCandidate.name}" for task "${task.title}"`);
108
+ await this.handleExistingSkill(task, chunks, nameCandidate);
109
+ } else {
110
+ await this.handleNewSkill(task, chunks);
111
+ }
100
112
  } else {
101
113
  await this.handleNewSkill(task, chunks);
102
114
  }
@@ -278,7 +290,12 @@ Use selectedIndex 0 when none is highly relevant.`;
278
290
 
279
291
  if (upgraded) {
280
292
  this.store.linkTaskSkill(task.id, freshSkill.id, "evolved_from", freshSkill.version + 1);
281
- this.installer.syncIfInstalled(freshSkill.name);
293
+ if (freshSkill.installed) {
294
+ this.installer.syncIfInstalled(freshSkill.name);
295
+ } else {
296
+ this.autoInstallIfNeeded(freshSkill);
297
+ }
298
+ this.onSkillEvolved?.(freshSkill.name, "upgraded");
282
299
  } else {
283
300
  this.store.linkTaskSkill(task.id, freshSkill.id, "applied_to", freshSkill.version);
284
301
  }
@@ -300,6 +317,13 @@ Use selectedIndex 0 when none is highly relevant.`;
300
317
  const evalResult = await this.evaluator.evaluateCreate(task);
301
318
 
302
319
  if (evalResult.shouldGenerate && evalResult.confidence >= minConfidence) {
320
+ const existingByName = this.store.getSkillByName(evalResult.suggestedName);
321
+ if (existingByName && (existingByName.status === "active" || existingByName.status === "draft")) {
322
+ this.ctx.log.info(`SkillEvolver: skill "${evalResult.suggestedName}" already exists, redirecting to upgrade instead of create`);
323
+ await this.handleExistingSkill(task, chunks, existingByName);
324
+ return;
325
+ }
326
+
303
327
  this.ctx.log.info(`SkillEvolver: generating new skill "${evalResult.suggestedName}" — ${evalResult.reason}`);
304
328
  this.store.setTaskSkillMeta(task.id, { skillStatus: "generating", skillReason: evalResult.reason });
305
329
 
@@ -307,11 +331,9 @@ Use selectedIndex 0 when none is highly relevant.`;
307
331
  this.markChunksWithSkill(chunks, skill.id);
308
332
  this.store.linkTaskSkill(task.id, skill.id, "generated_from", 1);
309
333
  this.store.setTaskSkillMeta(task.id, { skillStatus: "generated", skillReason: evalResult.reason });
334
+ this.onSkillEvolved?.(skill.name, "created");
310
335
 
311
- const autoInstall = this.ctx.config.skillEvolution?.autoInstall ?? DEFAULTS.skillAutoInstall;
312
- if (autoInstall && skill.status === "active") {
313
- this.installer.install(skill.id);
314
- }
336
+ this.autoInstallIfNeeded(skill);
315
337
  } else {
316
338
  const reason = evalResult.reason || `confidence不足 (${evalResult.confidence} < ${minConfidence})`;
317
339
  this.ctx.log.debug(`SkillEvolver: task "${task.title}" not worth generating skill — ${reason}`);
@@ -326,6 +348,41 @@ Use selectedIndex 0 when none is highly relevant.`;
326
348
  this.ctx.log.debug(`SkillEvolver: marked ${chunks.length} chunks with skill_id=${skillId}`);
327
349
  }
328
350
 
351
+ private async findSkillByNameSimilarity(task: Task): Promise<Skill | null> {
352
+ const query = task.title.slice(0, 200);
353
+ const owner = task.owner ?? "agent:main";
354
+
355
+ try {
356
+ const ftsHits = this.store.skillFtsSearch(query, 5, "mix", owner);
357
+ for (const hit of ftsHits) {
358
+ if (hit.score < 0.5) continue;
359
+ const skill = this.store.getSkill(hit.skillId);
360
+ if (skill && (skill.status === "active" || skill.status === "draft")) {
361
+ return skill;
362
+ }
363
+ }
364
+ } catch { /* best-effort */ }
365
+
366
+ return null;
367
+ }
368
+
369
+ private autoInstallIfNeeded(skill: Skill): void {
370
+ if (skill.status !== "active") return;
371
+
372
+ const explicitAutoInstall = this.ctx.config.skillEvolution?.autoInstall ?? DEFAULTS.skillAutoInstall;
373
+ if (explicitAutoInstall) {
374
+ this.installer.install(skill.id);
375
+ this.ctx.log.info(`SkillEvolver: auto-installed "${skill.name}" (explicit autoInstall=true)`);
376
+ return;
377
+ }
378
+
379
+ const manifest = SkillInstaller.buildManifest(skill.dirPath, !!skill.installed, skill.name);
380
+ if (manifest.installMode === "install_recommended") {
381
+ this.installer.install(skill.id);
382
+ this.ctx.log.info(`SkillEvolver: auto-installed "${skill.name}" (install_recommended: ${manifest.scriptsCount} scripts, ${Math.round(manifest.totalSize / 1024)}KB)`);
383
+ }
384
+ }
385
+
329
386
  private readSkillContent(skill: Skill): string | null {
330
387
  const filePath = path.join(skill.dirPath, "SKILL.md");
331
388
  try {