@memtensor/memos-local-openclaw-plugin 1.0.4-beta.10 → 1.0.4-beta.12

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 (72) hide show
  1. package/dist/client/connector.d.ts +5 -0
  2. package/dist/client/connector.d.ts.map +1 -1
  3. package/dist/client/connector.js +38 -8
  4. package/dist/client/connector.js.map +1 -1
  5. package/dist/hub/server.d.ts +1 -0
  6. package/dist/hub/server.d.ts.map +1 -1
  7. package/dist/hub/server.js +143 -32
  8. package/dist/hub/server.js.map +1 -1
  9. package/dist/hub/user-manager.d.ts +9 -0
  10. package/dist/hub/user-manager.d.ts.map +1 -1
  11. package/dist/hub/user-manager.js +26 -2
  12. package/dist/hub/user-manager.js.map +1 -1
  13. package/dist/ingest/chunker.d.ts +2 -1
  14. package/dist/ingest/chunker.d.ts.map +1 -1
  15. package/dist/ingest/chunker.js +14 -10
  16. package/dist/ingest/chunker.js.map +1 -1
  17. package/dist/recall/engine.d.ts.map +1 -1
  18. package/dist/recall/engine.js +7 -2
  19. package/dist/recall/engine.js.map +1 -1
  20. package/dist/sharing/types.d.ts +1 -1
  21. package/dist/sharing/types.d.ts.map +1 -1
  22. package/dist/skill/evolver.d.ts +2 -0
  23. package/dist/skill/evolver.d.ts.map +1 -1
  24. package/dist/skill/evolver.js +56 -5
  25. package/dist/skill/evolver.js.map +1 -1
  26. package/dist/skill/generator.d.ts +2 -0
  27. package/dist/skill/generator.d.ts.map +1 -1
  28. package/dist/skill/generator.js +45 -3
  29. package/dist/skill/generator.js.map +1 -1
  30. package/dist/skill/installer.d.ts +26 -0
  31. package/dist/skill/installer.d.ts.map +1 -1
  32. package/dist/skill/installer.js +80 -4
  33. package/dist/skill/installer.js.map +1 -1
  34. package/dist/skill/upgrader.d.ts +2 -0
  35. package/dist/skill/upgrader.d.ts.map +1 -1
  36. package/dist/skill/upgrader.js +139 -1
  37. package/dist/skill/upgrader.js.map +1 -1
  38. package/dist/skill/validator.d.ts +3 -0
  39. package/dist/skill/validator.d.ts.map +1 -1
  40. package/dist/skill/validator.js +75 -0
  41. package/dist/skill/validator.js.map +1 -1
  42. package/dist/storage/sqlite.d.ts +28 -0
  43. package/dist/storage/sqlite.d.ts.map +1 -1
  44. package/dist/storage/sqlite.js +155 -16
  45. package/dist/storage/sqlite.js.map +1 -1
  46. package/dist/types.d.ts +10 -0
  47. package/dist/types.d.ts.map +1 -1
  48. package/dist/types.js +4 -0
  49. package/dist/types.js.map +1 -1
  50. package/dist/viewer/html.d.ts.map +1 -1
  51. package/dist/viewer/html.js +64 -24
  52. package/dist/viewer/html.js.map +1 -1
  53. package/dist/viewer/server.d.ts.map +1 -1
  54. package/dist/viewer/server.js +39 -20
  55. package/dist/viewer/server.js.map +1 -1
  56. package/index.ts +338 -33
  57. package/package.json +1 -1
  58. package/src/client/connector.ts +43 -8
  59. package/src/hub/server.ts +142 -31
  60. package/src/hub/user-manager.ts +42 -6
  61. package/src/ingest/chunker.ts +19 -13
  62. package/src/recall/engine.ts +7 -2
  63. package/src/sharing/types.ts +1 -1
  64. package/src/skill/evolver.ts +58 -6
  65. package/src/skill/generator.ts +44 -5
  66. package/src/skill/installer.ts +107 -4
  67. package/src/skill/upgrader.ts +139 -1
  68. package/src/skill/validator.ts +79 -0
  69. package/src/storage/sqlite.ts +174 -16
  70. package/src/types.ts +11 -0
  71. package/src/viewer/html.ts +64 -24
  72. package/src/viewer/server.ts +39 -20
package/src/hub/server.ts CHANGED
@@ -169,6 +169,20 @@ export class HubServer {
169
169
  });
170
170
  }
171
171
 
172
+ private embedSkillAsync(skillId: string, name: string, description: string, sourceUserId: string, sourceSkillId: string): void {
173
+ const embedder = this.opts.embedder;
174
+ if (!embedder) return;
175
+ const text = `${name}: ${description}`;
176
+ embedder.embed([text]).then((vectors) => {
177
+ if (vectors[0]) {
178
+ this.opts.store.upsertHubSkillEmbedding(skillId, Array.from(vectors[0]), sourceUserId, sourceSkillId);
179
+ this.opts.log.info(`hub: embedded shared skill ${skillId}`);
180
+ }
181
+ }).catch((err) => {
182
+ this.opts.log.warn(`hub: embedding shared skill failed: ${err}`);
183
+ });
184
+ }
185
+
172
186
  private embedMemoryAsync(memoryId: string, summary: string, content: string): void {
173
187
  const embedder = this.opts.embedder;
174
188
  if (!embedder) return;
@@ -205,46 +219,70 @@ export class HubServer {
205
219
  || (req.headers["x-client-ip"] as string)?.trim()
206
220
  || (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
207
221
  || req.socket.remoteAddress || "";
208
- const existingUsers = this.opts.store.listHubUsers();
209
- const existingUser = existingUsers.find(u => u.username === username);
222
+ const identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : "";
223
+
224
+ let existingUser = identityKey
225
+ ? this.userManager.findByIdentityKey(identityKey)
226
+ : null;
227
+ if (!existingUser) {
228
+ const existingUsers = this.opts.store.listHubUsers();
229
+ existingUser = existingUsers.find(u => u.username === username && u.status !== "left" && u.status !== "removed") ?? null;
230
+ }
231
+
210
232
  if (existingUser) {
211
233
  try { this.opts.store.updateHubUserActivity(existingUser.id, joinIp); } catch { /* best-effort */ }
234
+
212
235
  if (existingUser.status === "active") {
213
236
  const token = issueUserToken(
214
237
  { userId: existingUser.id, username: existingUser.username, role: existingUser.role, status: "active" },
215
238
  this.authSecret,
216
239
  );
217
240
  this.userManager.approveUser(existingUser.id, token);
218
- return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token });
241
+ if (identityKey && !existingUser.identityKey) {
242
+ this.opts.store.upsertHubUser({ ...existingUser, identityKey });
243
+ }
244
+ return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token, identityKey: existingUser.identityKey || identityKey });
219
245
  }
220
246
  if (existingUser.status === "pending") {
221
247
  this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
222
- return this.json(res, 200, { status: "pending", userId: existingUser.id });
248
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
223
249
  }
224
250
  if (existingUser.status === "rejected") {
225
251
  if (body.reapply === true) {
226
252
  this.userManager.resetToPending(existingUser.id);
227
253
  this.notifyAdmins("user_join_request", "user", username, "");
228
254
  this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
229
- return this.json(res, 200, { status: "pending", userId: existingUser.id });
255
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
230
256
  }
231
257
  return this.json(res, 200, { status: "rejected", userId: existingUser.id });
232
258
  }
233
259
  if (existingUser.status === "removed") {
234
- this.userManager.resetToPending(existingUser.id);
235
- this.notifyAdmins("user_join_request", "user", username, "");
236
- this.opts.log.info(`Hub: removed user "${username}" (${existingUser.id}) re-applied, reset to pending`);
237
- return this.json(res, 200, { status: "pending", userId: existingUser.id });
260
+ this.userManager.rejoinUser(existingUser.id);
261
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
262
+ this.opts.log.info(`Hub: removed user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
263
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
264
+ }
265
+ if (existingUser.status === "left") {
266
+ this.userManager.rejoinUser(existingUser.id);
267
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
268
+ this.opts.log.info(`Hub: left user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
269
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
270
+ }
271
+ if (existingUser.status === "blocked") {
272
+ return this.json(res, 200, { status: "blocked", userId: existingUser.id });
238
273
  }
239
274
  }
275
+
276
+ const generatedIdentityKey = identityKey || randomUUID();
240
277
  const user = this.userManager.createPendingUser({
241
278
  username,
242
279
  deviceName: typeof body.deviceName === "string" ? body.deviceName : undefined,
280
+ identityKey: generatedIdentityKey,
243
281
  });
244
282
  try { this.opts.store.updateHubUserActivity(user.id, joinIp); } catch { /* best-effort */ }
245
283
  this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
246
284
  this.notifyAdmins("user_join_request", "user", username, "");
247
- return this.json(res, 200, { status: "pending", userId: user.id });
285
+ return this.json(res, 200, { status: "pending", userId: user.id, identityKey: generatedIdentityKey });
248
286
  }
249
287
 
250
288
  if (req.method === "POST" && routePath === "/api/v1/hub/registration-status") {
@@ -262,6 +300,15 @@ export class HubServer {
262
300
  if (user.status === "rejected") {
263
301
  return this.json(res, 200, { status: "rejected" });
264
302
  }
303
+ if (user.status === "blocked") {
304
+ return this.json(res, 200, { status: "blocked" });
305
+ }
306
+ if (user.status === "left") {
307
+ return this.json(res, 200, { status: "left" });
308
+ }
309
+ if (user.status === "removed") {
310
+ return this.json(res, 200, { status: "removed" });
311
+ }
265
312
  if (user.status === "active") {
266
313
  const token = issueUserToken(
267
314
  { userId: user.id, username: user.username, role: user.role, status: user.status },
@@ -286,12 +333,10 @@ export class HubServer {
286
333
  }
287
334
 
288
335
  if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
289
- try {
290
- this.opts.store.updateHubUserActivity(auth.userId, "", 0);
291
- } catch { /* best-effort */ }
336
+ this.userManager.markUserLeft(auth.userId);
292
337
  this.knownOnlineUsers.delete(auth.userId);
293
338
  this.notifyAdmins("user_offline", "user", auth.username, auth.userId);
294
- this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily`);
339
+ this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily, status set to "left"`);
295
340
  return this.json(res, 200, { ok: true });
296
341
  }
297
342
 
@@ -332,18 +377,33 @@ export class HubServer {
332
377
  if (req.method === "POST" && routePath === "/api/v1/hub/admin/approve-user") {
333
378
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
334
379
  const body = await this.readJson(req);
335
- const token = issueUserToken({ userId: String(body.userId), username: String(body.username || ""), role: "member", status: "active" }, this.authSecret);
336
- const approved = this.userManager.approveUser(String(body.userId), token);
380
+ const userId = String(body.userId);
381
+ const username = String(body.username || "");
382
+ const token = issueUserToken({ userId, username, role: "member", status: "active" }, this.authSecret);
383
+ const approved = this.userManager.approveUser(userId, token);
337
384
  if (!approved) return this.json(res, 404, { error: "not_found" });
338
- try { this.opts.store.updateHubUserActivity(String(body.userId), ""); } catch { /* best-effort */ }
385
+ try { this.opts.store.updateHubUserActivity(userId, ""); } catch { /* best-effort */ }
386
+ try {
387
+ this.opts.store.insertHubNotification({
388
+ id: randomUUID(), userId, type: "membership_approved",
389
+ resource: "user", title: `Your request to join team "${this.teamName}" has been approved. Welcome!`,
390
+ });
391
+ } catch { /* best-effort */ }
339
392
  return this.json(res, 200, { status: "active", token });
340
393
  }
341
394
 
342
395
  if (req.method === "POST" && routePath === "/api/v1/hub/admin/reject-user") {
343
396
  if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
344
397
  const body = await this.readJson(req);
345
- const rejected = this.userManager.rejectUser(String(body.userId));
398
+ const userId = String(body.userId);
399
+ const rejected = this.userManager.rejectUser(userId);
346
400
  if (!rejected) return this.json(res, 404, { error: "not_found" });
401
+ try {
402
+ this.opts.store.insertHubNotification({
403
+ id: randomUUID(), userId, type: "membership_rejected",
404
+ resource: "user", title: `Your request to join team "${this.teamName}" has been declined.`,
405
+ });
406
+ } catch { /* best-effort */ }
347
407
  return this.json(res, 200, { status: "rejected" });
348
408
  }
349
409
 
@@ -609,20 +669,70 @@ export class HubServer {
609
669
  }
610
670
 
611
671
  if (req.method === "GET" && routePath === "/api/v1/hub/skills") {
612
- const hits = this.opts.store.searchHubSkills(String(url.searchParams.get("query") || ""), {
672
+ const skillQuery = String(url.searchParams.get("query") || "");
673
+ const skillMaxResults = Number(url.searchParams.get("maxResults") || 10);
674
+ const ftsSkillHits = this.opts.store.searchHubSkills(skillQuery, {
613
675
  userId: auth.userId,
614
- maxResults: Number(url.searchParams.get("maxResults") || 10),
615
- }).map(({ hit }) => ({
616
- skillId: hit.id,
617
- name: hit.name,
618
- description: hit.description,
619
- version: hit.version,
620
- visibility: hit.visibility,
621
- groupName: hit.group_name,
622
- ownerName: hit.owner_name || "unknown",
623
- ownerStatus: hit.owner_status || "",
624
- qualityScore: hit.quality_score,
625
- }));
676
+ maxResults: skillMaxResults * 2,
677
+ });
678
+
679
+ let mergedSkillIds: string[];
680
+ if (this.opts.embedder && skillQuery) {
681
+ try {
682
+ const [queryVec] = await this.opts.embedder.embed([skillQuery]);
683
+ if (queryVec) {
684
+ const skillEmbs = this.opts.store.getVisibleHubSkillEmbeddings();
685
+ const cosineSim = (vec: Float32Array) => {
686
+ let dot = 0, nA = 0, nB = 0;
687
+ for (let i = 0; i < queryVec.length && i < vec.length; i++) {
688
+ dot += queryVec[i] * vec[i]; nA += queryVec[i] * queryVec[i]; nB += vec[i] * vec[i];
689
+ }
690
+ return nA > 0 && nB > 0 ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
691
+ };
692
+ const vecScored = skillEmbs
693
+ .map(e => ({ id: e.skillId, score: cosineSim(e.vector) }))
694
+ .filter(e => e.score > 0.3)
695
+ .sort((a, b) => b.score - a.score)
696
+ .slice(0, skillMaxResults * 2);
697
+
698
+ const K = 60;
699
+ const rrfScores = new Map<string, number>();
700
+ ftsSkillHits.forEach(({ hit }, idx) => {
701
+ rrfScores.set(hit.id, (rrfScores.get(hit.id) ?? 0) + 1 / (K + idx + 1));
702
+ });
703
+ vecScored.forEach(({ id }, idx) => {
704
+ rrfScores.set(id, (rrfScores.get(id) ?? 0) + 1 / (K + idx + 1));
705
+ });
706
+ mergedSkillIds = [...rrfScores.entries()].sort((a, b) => b[1] - a[1]).slice(0, skillMaxResults).map(([id]) => id);
707
+ } else {
708
+ mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
709
+ }
710
+ } catch {
711
+ mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
712
+ }
713
+ } else {
714
+ mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
715
+ }
716
+
717
+ const ftsSkillMap = new Map(ftsSkillHits.map(({ hit }) => [hit.id, hit]));
718
+ const hits = mergedSkillIds.map(id => {
719
+ const hit = ftsSkillMap.get(id);
720
+ if (hit) {
721
+ return {
722
+ skillId: hit.id, name: hit.name, description: hit.description,
723
+ version: hit.version, visibility: hit.visibility, groupName: hit.group_name,
724
+ ownerName: hit.owner_name || "unknown", ownerStatus: hit.owner_status || "",
725
+ qualityScore: hit.quality_score,
726
+ };
727
+ }
728
+ const skill = this.opts.store.getHubSkillById(id);
729
+ if (!skill) return null;
730
+ return {
731
+ skillId: skill.id, name: skill.name, description: skill.description,
732
+ version: skill.version, visibility: skill.visibility, groupName: "",
733
+ ownerName: "unknown", ownerStatus: "", qualityScore: skill.qualityScore,
734
+ };
735
+ }).filter(Boolean);
626
736
  return this.json(res, 200, { hits });
627
737
  }
628
738
 
@@ -648,6 +758,7 @@ export class HubServer {
648
758
  createdAt: existing?.createdAt ?? Date.now(),
649
759
  updatedAt: Date.now(),
650
760
  });
761
+ this.embedSkillAsync(skillId, String(metadata.name || sourceSkillId), String(metadata.description || ""), auth.userId, sourceSkillId);
651
762
  if (!existing) {
652
763
  this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
653
764
  }
@@ -4,13 +4,24 @@ 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; lastIp: string; lastActiveAt: 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,
@@ -22,11 +33,36 @@ export class HubUserManager {
22
33
  approvedAt: null,
23
34
  lastIp: "",
24
35
  lastActiveAt: null,
36
+ identityKey: input.identityKey || "",
25
37
  };
26
38
  this.store.upsertHubUser(user);
27
39
  return user;
28
40
  }
29
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
+
30
66
  listPendingUsers(): ManagedHubUser[] {
31
67
  return this.store.listHubUsers("pending");
32
68
  }
@@ -105,7 +141,7 @@ export class HubUserManager {
105
141
 
106
142
  isUsernameTaken(username: string, excludeUserId?: string): boolean {
107
143
  const users = this.store.listHubUsers();
108
- 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");
109
145
  }
110
146
 
111
147
  updateUsername(userId: string, newUsername: string): ManagedHubUser | null {
@@ -119,10 +155,10 @@ export class HubUserManager {
119
155
  rejectUser(userId: string): ManagedHubUser | null {
120
156
  const user = this.store.getHubUser(userId);
121
157
  if (!user) return null;
122
- const updated = {
158
+ const updated: ManagedHubUser = {
123
159
  ...user,
124
160
  status: "rejected" as const,
125
- approvedAt: Date.now(),
161
+ rejectedAt: Date.now(),
126
162
  };
127
163
  this.store.upsertHubUser(updated);
128
164
  return updated;
@@ -1,6 +1,8 @@
1
+ export type ChunkKind = "paragraph" | "code_block" | "error_stack" | "list" | "command";
2
+
1
3
  export interface RawChunk {
2
4
  content: string;
3
- kind: "paragraph";
5
+ kind: ChunkKind;
4
6
  }
5
7
 
6
8
  const MAX_CHUNK_CHARS = 3000;
@@ -28,21 +30,25 @@ const COMMAND_LINE_RE = /^(?:\$|>|#)\s+.+$/gm;
28
30
  */
29
31
  export function chunkText(text: string): RawChunk[] {
30
32
  let remaining = text;
31
- const slots: Array<{ placeholder: string; content: string }> = [];
33
+ const slots: Array<{ placeholder: string; content: string; kind: ChunkKind }> = [];
32
34
  let counter = 0;
33
35
 
34
- function ph(content: string): string {
36
+ function ph(content: string, kind: ChunkKind = "paragraph"): string {
35
37
  const tag = `\x00SLOT_${counter++}\x00`;
36
- slots.push({ placeholder: tag, content: content.trim() });
38
+ slots.push({ placeholder: tag, content: content.trim(), kind });
37
39
  return tag;
38
40
  }
39
41
 
40
- remaining = remaining.replace(FENCED_CODE_RE, (m) => ph(m));
42
+ remaining = remaining.replace(FENCED_CODE_RE, (m) => ph(m, "code_block"));
41
43
  remaining = extractBraceBlocks(remaining, ph);
42
44
 
43
- const structural: RegExp[] = [ERROR_STACK_RE, LIST_BLOCK_RE, COMMAND_LINE_RE];
44
- for (const re of structural) {
45
- remaining = remaining.replace(re, (m) => ph(m));
45
+ const structuralKinds: Array<[RegExp, ChunkKind]> = [
46
+ [ERROR_STACK_RE, "error_stack"],
47
+ [LIST_BLOCK_RE, "list"],
48
+ [COMMAND_LINE_RE, "command"],
49
+ ];
50
+ for (const [re, kind] of structuralKinds) {
51
+ remaining = remaining.replace(re, (m) => ph(m, kind));
46
52
  }
47
53
 
48
54
  const raw: RawChunk[] = [];
@@ -57,7 +63,7 @@ export function chunkText(text: string): RawChunk[] {
57
63
  for (const part of parts) {
58
64
  const slot = slots.find((s) => s.placeholder === part);
59
65
  if (slot) {
60
- raw.push({ content: slot.content, kind: "paragraph" });
66
+ raw.push({ content: slot.content, kind: slot.kind });
61
67
  } else if (part.trim().length >= MIN_CHUNK_CHARS) {
62
68
  raw.push({ content: part.trim(), kind: "paragraph" });
63
69
  }
@@ -69,7 +75,7 @@ export function chunkText(text: string): RawChunk[] {
69
75
 
70
76
  for (const s of slots) {
71
77
  if (!raw.some((c) => c.content === s.content)) {
72
- raw.push({ content: s.content, kind: "paragraph" });
78
+ raw.push({ content: s.content, kind: s.kind });
73
79
  }
74
80
  }
75
81
 
@@ -85,7 +91,7 @@ export function chunkText(text: string): RawChunk[] {
85
91
  */
86
92
  function extractBraceBlocks(
87
93
  text: string,
88
- ph: (content: string) => string,
94
+ ph: (content: string, kind?: ChunkKind) => string,
89
95
  ): string {
90
96
  const lines = text.split("\n");
91
97
  const result: string[] = [];
@@ -119,7 +125,7 @@ function extractBraceBlocks(
119
125
  if (depth <= 0 || (BLOCK_CLOSE_RE.test(line) && depth <= 0)) {
120
126
  const block = blockLines.join("\n");
121
127
  if (block.trim().length >= MIN_CHUNK_CHARS) {
122
- result.push(ph(block));
128
+ result.push(ph(block, "code_block"));
123
129
  } else {
124
130
  result.push(block);
125
131
  }
@@ -135,7 +141,7 @@ function extractBraceBlocks(
135
141
  if (blockLines.length > 0) {
136
142
  const block = blockLines.join("\n");
137
143
  if (block.trim().length >= MIN_CHUNK_CHARS) {
138
- result.push(ph(block));
144
+ result.push(ph(block, "code_block"));
139
145
  } else {
140
146
  result.push(block);
141
147
  }
@@ -74,11 +74,14 @@ 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)
77
+ // Step 1c: Hub memories search only in Hub mode where local DB owns the
78
+ // hub_memories data and embeddings were generated by the same Embedder.
79
+ // Client mode must use remote API (hubSearchMemories) to avoid cross-model
80
+ // embedding mismatch.
78
81
  let hubMemFtsRanked: Array<{ id: string; score: number }> = [];
79
82
  let hubMemVecRanked: Array<{ id: string; score: number }> = [];
80
83
  let hubMemPatternRanked: Array<{ id: string; score: number }> = [];
81
- if (query && this.ctx.config.sharing?.enabled) {
84
+ if (query && this.ctx.config.sharing?.enabled && this.ctx.config.sharing.role === "hub") {
82
85
  try {
83
86
  const hubFtsHits = this.store.searchHubMemories(query, { maxResults: candidatePool });
84
87
  hubMemFtsRanked = hubFtsHits.map(({ hit }, i) => ({
@@ -202,6 +205,7 @@ export class RecallEngine {
202
205
  taskId: null,
203
206
  skillId: null,
204
207
  owner: `hub-user:${mem.sourceUserId}`,
208
+ origin: "hub-memory",
205
209
  source: {
206
210
  ts: mem.createdAt,
207
211
  role: (mem.role || "assistant") as any,
@@ -228,6 +232,7 @@ export class RecallEngine {
228
232
  score: Math.round(candidate.score * 1000) / 1000,
229
233
  taskId: chunk.taskId,
230
234
  skillId: chunk.skillId,
235
+ origin: chunk.owner === "public" ? "local-shared" : "local",
231
236
  source: {
232
237
  ts: chunk.createdAt,
233
238
  role: chunk.role,
@@ -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" | "removed";
15
+ export type UserStatus = "pending" | "active" | "blocked" | "rejected" | "removed" | "left";
16
16
 
17
17
  export type { ClientModeConfig, HubModeConfig, SharingCapabilities, SharingConfig, SharingRole };
18
18
 
@@ -9,7 +9,7 @@ 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
15
  export type SkillEvolvedCallback = (skillName: string, upgradeType: "created" | "upgraded") => void;
@@ -96,10 +96,19 @@ export class SkillEvolver {
96
96
  return;
97
97
  }
98
98
 
99
+ const preferUpgrade = this.ctx.config.skillEvolution?.preferUpgradeExisting ?? DEFAULTS.skillPreferUpgrade;
99
100
  const relatedSkill = await this.findRelatedSkill(task);
100
101
 
101
102
  if (relatedSkill) {
102
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
+ }
103
112
  } else {
104
113
  await this.handleNewSkill(task, chunks);
105
114
  }
@@ -281,7 +290,11 @@ Use selectedIndex 0 when none is highly relevant.`;
281
290
 
282
291
  if (upgraded) {
283
292
  this.store.linkTaskSkill(task.id, freshSkill.id, "evolved_from", freshSkill.version + 1);
284
- this.installer.syncIfInstalled(freshSkill.name);
293
+ if (freshSkill.installed) {
294
+ this.installer.syncIfInstalled(freshSkill.name);
295
+ } else {
296
+ this.autoInstallIfNeeded(freshSkill);
297
+ }
285
298
  this.onSkillEvolved?.(freshSkill.name, "upgraded");
286
299
  } else {
287
300
  this.store.linkTaskSkill(task.id, freshSkill.id, "applied_to", freshSkill.version);
@@ -304,6 +317,13 @@ Use selectedIndex 0 when none is highly relevant.`;
304
317
  const evalResult = await this.evaluator.evaluateCreate(task);
305
318
 
306
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
+
307
327
  this.ctx.log.info(`SkillEvolver: generating new skill "${evalResult.suggestedName}" — ${evalResult.reason}`);
308
328
  this.store.setTaskSkillMeta(task.id, { skillStatus: "generating", skillReason: evalResult.reason });
309
329
 
@@ -313,10 +333,7 @@ Use selectedIndex 0 when none is highly relevant.`;
313
333
  this.store.setTaskSkillMeta(task.id, { skillStatus: "generated", skillReason: evalResult.reason });
314
334
  this.onSkillEvolved?.(skill.name, "created");
315
335
 
316
- const autoInstall = this.ctx.config.skillEvolution?.autoInstall ?? DEFAULTS.skillAutoInstall;
317
- if (autoInstall && skill.status === "active") {
318
- this.installer.install(skill.id);
319
- }
336
+ this.autoInstallIfNeeded(skill);
320
337
  } else {
321
338
  const reason = evalResult.reason || `confidence不足 (${evalResult.confidence} < ${minConfidence})`;
322
339
  this.ctx.log.debug(`SkillEvolver: task "${task.title}" not worth generating skill — ${reason}`);
@@ -331,6 +348,41 @@ Use selectedIndex 0 when none is highly relevant.`;
331
348
  this.ctx.log.debug(`SkillEvolver: marked ${chunks.length} chunks with skill_id=${skillId}`);
332
349
  }
333
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
+
334
386
  private readSkillContent(skill: Skill): string | null {
335
387
  const filePath = path.join(skill.dirPath, "SKILL.md");
336
388
  try {
@@ -484,14 +484,55 @@ export class SkillGenerator {
484
484
 
485
485
  private buildConversationText(chunks: Chunk[]): string {
486
486
  const lines: string[] = [];
487
+ const redact = this.ctx.config.skillEvolution?.redactSensitiveInSkill ?? true;
488
+
487
489
  for (const c of chunks) {
488
- if (c.role !== "user" && c.role !== "assistant") continue;
489
- const roleLabel = c.role === "user" ? "User" : "Assistant";
490
- lines.push(`[${roleLabel}]: ${c.content}`);
490
+ let roleLabel: string;
491
+ switch (c.role) {
492
+ case "user": roleLabel = "User"; break;
493
+ case "assistant": roleLabel = "Assistant"; break;
494
+ case "tool": roleLabel = "Tool"; break;
495
+ case "system": roleLabel = "System"; break;
496
+ default: continue;
497
+ }
498
+
499
+ let content = c.content;
500
+ if (c.role === "system") continue;
501
+
502
+ if (c.role === "tool") {
503
+ content = this.truncateToolOutput(content);
504
+ }
505
+
506
+ if (redact) {
507
+ content = SkillGenerator.redactSensitive(content);
508
+ }
509
+
510
+ lines.push(`[${roleLabel}]: ${content}`);
491
511
  }
492
512
  return lines.join("\n\n");
493
513
  }
494
514
 
515
+ private truncateToolOutput(content: string): string {
516
+ const MAX_TOOL_OUTPUT = 1500;
517
+ if (content.length <= MAX_TOOL_OUTPUT) return content;
518
+ const head = content.slice(0, MAX_TOOL_OUTPUT * 0.6);
519
+ const tail = content.slice(-MAX_TOOL_OUTPUT * 0.3);
520
+ return `${head}\n... (truncated ${content.length - MAX_TOOL_OUTPUT} chars) ...\n${tail}`;
521
+ }
522
+
523
+ static redactSensitive(text: string): string {
524
+ let result = text;
525
+ result = result.replace(/\bsk-[a-zA-Z0-9]{20,}\b/g, "sk-***REDACTED***");
526
+ result = result.replace(/\bBearer\s+[a-zA-Z0-9_\-.]{20,}\b/g, "Bearer ***REDACTED***");
527
+ result = result.replace(/\bAKIA[0-9A-Z]{16}\b/g, "AKIA***REDACTED***");
528
+ result = result.replace(/(api[_-]?key|secret|token|password|credential)\s*[:=]\s*["']([^"']{8,})["']/gi,
529
+ (match, key) => `${key}="***REDACTED***"`);
530
+ result = result.replace(/\/Users\/[a-zA-Z0-9._-]+\//g, "/Users/****/");
531
+ result = result.replace(/\/home\/[a-zA-Z0-9._-]+\//g, "/home/****/");
532
+ result = result.replace(/C:\\Users\\[a-zA-Z0-9._-]+\\/g, "C:\\Users\\****\\");
533
+ return result;
534
+ }
535
+
495
536
  private parseDescription(content: string): string {
496
537
  const match = content.match(/description:\s*"([^"]+)"/);
497
538
  if (match) return match[1];
@@ -499,6 +540,4 @@ export class SkillGenerator {
499
540
  if (match2) return match2[1];
500
541
  return "";
501
542
  }
502
-
503
-
504
543
  }