@memtensor/memos-local-openclaw-plugin 1.0.4-beta.9 → 1.0.5

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 (100) hide show
  1. package/.env.example +7 -0
  2. package/README.md +94 -27
  3. package/dist/capture/index.js +3 -1
  4. package/dist/capture/index.js.map +1 -1
  5. package/dist/client/connector.d.ts +5 -0
  6. package/dist/client/connector.d.ts.map +1 -1
  7. package/dist/client/connector.js +89 -8
  8. package/dist/client/connector.js.map +1 -1
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +2 -1
  11. package/dist/config.js.map +1 -1
  12. package/dist/hub/server.d.ts +2 -0
  13. package/dist/hub/server.d.ts.map +1 -1
  14. package/dist/hub/server.js +240 -35
  15. package/dist/hub/server.js.map +1 -1
  16. package/dist/hub/user-manager.d.ts +9 -0
  17. package/dist/hub/user-manager.d.ts.map +1 -1
  18. package/dist/hub/user-manager.js +26 -2
  19. package/dist/hub/user-manager.js.map +1 -1
  20. package/dist/ingest/chunker.d.ts +2 -1
  21. package/dist/ingest/chunker.d.ts.map +1 -1
  22. package/dist/ingest/chunker.js +14 -10
  23. package/dist/ingest/chunker.js.map +1 -1
  24. package/dist/ingest/providers/index.js +2 -2
  25. package/dist/ingest/providers/index.js.map +1 -1
  26. package/dist/recall/engine.d.ts.map +1 -1
  27. package/dist/recall/engine.js +22 -4
  28. package/dist/recall/engine.js.map +1 -1
  29. package/dist/shared/llm-call.d.ts.map +1 -1
  30. package/dist/shared/llm-call.js +2 -1
  31. package/dist/shared/llm-call.js.map +1 -1
  32. package/dist/sharing/types.d.ts +1 -1
  33. package/dist/sharing/types.d.ts.map +1 -1
  34. package/dist/skill/evolver.d.ts +2 -0
  35. package/dist/skill/evolver.d.ts.map +1 -1
  36. package/dist/skill/evolver.js +56 -5
  37. package/dist/skill/evolver.js.map +1 -1
  38. package/dist/skill/generator.d.ts +2 -0
  39. package/dist/skill/generator.d.ts.map +1 -1
  40. package/dist/skill/generator.js +45 -3
  41. package/dist/skill/generator.js.map +1 -1
  42. package/dist/skill/installer.d.ts +26 -0
  43. package/dist/skill/installer.d.ts.map +1 -1
  44. package/dist/skill/installer.js +80 -4
  45. package/dist/skill/installer.js.map +1 -1
  46. package/dist/skill/upgrader.d.ts +2 -0
  47. package/dist/skill/upgrader.d.ts.map +1 -1
  48. package/dist/skill/upgrader.js +139 -1
  49. package/dist/skill/upgrader.js.map +1 -1
  50. package/dist/skill/validator.d.ts +3 -0
  51. package/dist/skill/validator.d.ts.map +1 -1
  52. package/dist/skill/validator.js +75 -0
  53. package/dist/skill/validator.js.map +1 -1
  54. package/dist/storage/sqlite.d.ts +57 -0
  55. package/dist/storage/sqlite.d.ts.map +1 -1
  56. package/dist/storage/sqlite.js +290 -35
  57. package/dist/storage/sqlite.js.map +1 -1
  58. package/dist/telemetry.d.ts +4 -1
  59. package/dist/telemetry.d.ts.map +1 -1
  60. package/dist/telemetry.js +39 -12
  61. package/dist/telemetry.js.map +1 -1
  62. package/dist/types.d.ts +10 -0
  63. package/dist/types.d.ts.map +1 -1
  64. package/dist/types.js +4 -0
  65. package/dist/types.js.map +1 -1
  66. package/dist/viewer/html.d.ts.map +1 -1
  67. package/dist/viewer/html.js +564 -225
  68. package/dist/viewer/html.js.map +1 -1
  69. package/dist/viewer/server.d.ts +9 -0
  70. package/dist/viewer/server.d.ts.map +1 -1
  71. package/dist/viewer/server.js +357 -108
  72. package/dist/viewer/server.js.map +1 -1
  73. package/index.ts +412 -53
  74. package/openclaw.plugin.json +1 -1
  75. package/package.json +2 -1
  76. package/prebuilds/darwin-arm64/better_sqlite3.node +0 -0
  77. package/prebuilds/darwin-x64/better_sqlite3.node +0 -0
  78. package/prebuilds/linux-x64/better_sqlite3.node +0 -0
  79. package/prebuilds/win32-x64/better_sqlite3.node +0 -0
  80. package/src/capture/index.ts +4 -1
  81. package/src/client/connector.ts +92 -8
  82. package/src/config.ts +2 -1
  83. package/src/hub/server.ts +235 -35
  84. package/src/hub/user-manager.ts +42 -6
  85. package/src/ingest/chunker.ts +19 -13
  86. package/src/ingest/providers/index.ts +2 -2
  87. package/src/recall/engine.ts +20 -4
  88. package/src/shared/llm-call.ts +2 -1
  89. package/src/sharing/types.ts +1 -1
  90. package/src/skill/evolver.ts +58 -6
  91. package/src/skill/generator.ts +44 -5
  92. package/src/skill/installer.ts +107 -4
  93. package/src/skill/upgrader.ts +139 -1
  94. package/src/skill/validator.ts +79 -0
  95. package/src/storage/sqlite.ts +318 -40
  96. package/src/telemetry.ts +39 -14
  97. package/src/types.ts +11 -0
  98. package/src/viewer/html.ts +564 -225
  99. package/src/viewer/server.ts +333 -105
  100. package/telemetry.credentials.json +5 -0
@@ -74,16 +74,28 @@ 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
- if (query && this.ctx.config.sharing?.enabled) {
83
+ let hubMemPatternRanked: Array<{ id: string; score: number }> = [];
84
+ if (query && this.ctx.config.sharing?.enabled && this.ctx.config.sharing.role === "hub") {
81
85
  try {
82
86
  const hubFtsHits = this.store.searchHubMemories(query, { maxResults: candidatePool });
83
87
  hubMemFtsRanked = hubFtsHits.map(({ hit }, i) => ({
84
88
  id: `hubmem:${hit.id}`, score: 1 / (i + 1),
85
89
  }));
86
90
  } catch { /* hub_memories table may not exist */ }
91
+ if (shortTerms.length > 0) {
92
+ try {
93
+ const hubPatternHits = this.store.hubMemoryPatternSearch(shortTerms, { limit: candidatePool });
94
+ hubMemPatternRanked = hubPatternHits.map((h, i) => ({
95
+ id: `hubmem:${h.memoryId}`, score: 1 / (i + 1),
96
+ }));
97
+ } catch { /* best-effort */ }
98
+ }
87
99
  try {
88
100
  const hubMemEmbs = this.store.getVisibleHubMemoryEmbeddings("");
89
101
  if (hubMemEmbs.length > 0) {
@@ -105,8 +117,9 @@ export class RecallEngine {
105
117
  }
106
118
  }
107
119
  } catch { /* best-effort */ }
108
- if (hubMemFtsRanked.length > 0 || hubMemVecRanked.length > 0) {
109
- this.ctx.log.debug(`recall: hub_memories candidates: fts=${hubMemFtsRanked.length}, vec=${hubMemVecRanked.length}`);
120
+ const hubTotal = hubMemFtsRanked.length + hubMemVecRanked.length + hubMemPatternRanked.length;
121
+ if (hubTotal > 0) {
122
+ this.ctx.log.debug(`recall: hub_memories candidates: fts=${hubMemFtsRanked.length}, vec=${hubMemVecRanked.length}, pattern=${hubMemPatternRanked.length}`);
110
123
  }
111
124
  }
112
125
 
@@ -116,6 +129,7 @@ export class RecallEngine {
116
129
  const allRankedLists = [ftsRanked, vecRanked, patternRanked];
117
130
  if (hubMemFtsRanked.length > 0) allRankedLists.push(hubMemFtsRanked);
118
131
  if (hubMemVecRanked.length > 0) allRankedLists.push(hubMemVecRanked);
132
+ if (hubMemPatternRanked.length > 0) allRankedLists.push(hubMemPatternRanked);
119
133
  const rrfScores = rrfFuse(allRankedLists, recallCfg.rrfK);
120
134
 
121
135
  if (rrfScores.size === 0) {
@@ -191,6 +205,7 @@ export class RecallEngine {
191
205
  taskId: null,
192
206
  skillId: null,
193
207
  owner: `hub-user:${mem.sourceUserId}`,
208
+ origin: "hub-memory",
194
209
  source: {
195
210
  ts: mem.createdAt,
196
211
  role: (mem.role || "assistant") as any,
@@ -217,6 +232,7 @@ export class RecallEngine {
217
232
  score: Math.round(candidate.score * 1000) / 1000,
218
233
  taskId: chunk.taskId,
219
234
  skillId: chunk.skillId,
235
+ origin: chunk.owner === "public" ? "local-shared" : "local",
220
236
  source: {
221
237
  ts: chunk.createdAt,
222
238
  role: chunk.role,
@@ -37,7 +37,8 @@ function defaultEndpointForProvider(provider: SummaryProvider, baseUrl: string):
37
37
  export function loadOpenClawFallbackConfig(log: Logger): SummarizerConfig | undefined {
38
38
  try {
39
39
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
40
- const cfgPath = path.join(home, ".openclaw", "openclaw.json");
40
+ const cfgPath = process.env.OPENCLAW_CONFIG_PATH
41
+ || path.join(process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw"), "openclaw.json");
41
42
  if (!fs.existsSync(cfgPath)) return undefined;
42
43
 
43
44
  const raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
@@ -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,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
  }
@@ -3,6 +3,26 @@ import * as path from "path";
3
3
  import type { SqliteStore } from "../storage/sqlite";
4
4
  import type { PluginContext } from "../types";
5
5
 
6
+ export type SkillInstallMode = "inline" | "on_demand" | "install_recommended";
7
+
8
+ export interface CompanionFileInfo {
9
+ relativePath: string;
10
+ size: number;
11
+ type: "script" | "reference" | "eval" | "other";
12
+ }
13
+
14
+ export interface SkillCompanionManifest {
15
+ hasCompanionFiles: boolean;
16
+ installMode: SkillInstallMode;
17
+ installed: boolean;
18
+ installedPath?: string;
19
+ files: CompanionFileInfo[];
20
+ totalSize: number;
21
+ scriptsCount: number;
22
+ referencesCount: number;
23
+ evalsCount: number;
24
+ }
25
+
6
26
  export class SkillInstaller {
7
27
  private workspaceSkillsDir: string;
8
28
 
@@ -13,6 +33,82 @@ export class SkillInstaller {
13
33
  this.workspaceSkillsDir = path.join(ctx.workspaceDir, "skills");
14
34
  }
15
35
 
36
+ getCompanionManifest(skillId: string): SkillCompanionManifest | null {
37
+ const skill = this.store.getSkill(skillId);
38
+ if (!skill) return null;
39
+ return SkillInstaller.buildManifest(skill.dirPath, !!skill.installed, skill.name, this.workspaceSkillsDir);
40
+ }
41
+
42
+ static buildManifest(dirPath: string, installed: boolean, skillName: string, workspaceSkillsDir?: string): SkillCompanionManifest {
43
+ const files: CompanionFileInfo[] = [];
44
+
45
+ const scanDir = (subDir: string, type: CompanionFileInfo["type"]) => {
46
+ const fullDir = path.join(dirPath, subDir);
47
+ if (!fs.existsSync(fullDir)) return;
48
+ try {
49
+ for (const f of fs.readdirSync(fullDir)) {
50
+ const fp = path.join(fullDir, f);
51
+ try {
52
+ const stat = fs.statSync(fp);
53
+ if (stat.isFile()) {
54
+ files.push({ relativePath: `${subDir}/${f}`, size: stat.size, type });
55
+ }
56
+ } catch { /* best-effort */ }
57
+ }
58
+ } catch { /* best-effort */ }
59
+ };
60
+
61
+ scanDir("scripts", "script");
62
+ scanDir("references", "reference");
63
+ scanDir("evals", "eval");
64
+
65
+ const scriptsCount = files.filter(f => f.type === "script").length;
66
+ const referencesCount = files.filter(f => f.type === "reference").length;
67
+ const evalsCount = files.filter(f => f.type === "eval").length;
68
+ const totalSize = files.reduce((sum, f) => sum + f.size, 0);
69
+ const hasCompanionFiles = files.filter(f => f.type !== "eval").length > 0;
70
+
71
+ let installMode: SkillInstallMode = "inline";
72
+ if (hasCompanionFiles) {
73
+ const executableScripts = files.filter(f => f.type === "script");
74
+ const largeFiles = files.filter(f => f.size > 5000);
75
+ if (executableScripts.length >= 3 || largeFiles.length >= 2 || totalSize > 20000) {
76
+ installMode = "install_recommended";
77
+ } else {
78
+ installMode = "on_demand";
79
+ }
80
+ }
81
+
82
+ const installedPath = installed && workspaceSkillsDir
83
+ ? path.join(workspaceSkillsDir, skillName)
84
+ : undefined;
85
+
86
+ return { hasCompanionFiles, installMode, installed, installedPath, files, totalSize, scriptsCount, referencesCount, evalsCount };
87
+ }
88
+
89
+ readCompanionFile(skillId: string, relativePath: string): { content: string; size: number } | { error: string } {
90
+ const skill = this.store.getSkill(skillId);
91
+ if (!skill) return { error: "Skill not found" };
92
+
93
+ const normalized = relativePath.replace(/\.\./g, "");
94
+ const fullPath = path.join(skill.dirPath, normalized);
95
+
96
+ if (!fullPath.startsWith(skill.dirPath)) {
97
+ return { error: "Path traversal not allowed" };
98
+ }
99
+
100
+ if (!fs.existsSync(fullPath)) {
101
+ return { error: `File not found: ${relativePath}` };
102
+ }
103
+
104
+ try {
105
+ const content = fs.readFileSync(fullPath, "utf-8");
106
+ return { content, size: content.length };
107
+ } catch (err) {
108
+ return { error: `Cannot read file: ${err}` };
109
+ }
110
+ }
111
+
16
112
  install(skillId: string): { installed: boolean; path: string; message: string } {
17
113
  const skill = this.store.getSkill(skillId);
18
114
  if (!skill) return { installed: false, path: "", message: "Skill not found" };
@@ -22,8 +118,7 @@ export class SkillInstaller {
22
118
  }
23
119
 
24
120
  const dstDir = path.join(this.workspaceSkillsDir, skill.name);
25
- fs.mkdirSync(dstDir, { recursive: true });
26
- fs.cpSync(skill.dirPath, dstDir, { recursive: true });
121
+ this.cleanSync(skill.dirPath, dstDir);
27
122
  this.store.updateSkill(skillId, { installed: 1 });
28
123
 
29
124
  this.ctx.log.info(`Skill installed: "${skill.name}" v${skill.version} → ${dstDir}`);
@@ -51,9 +146,17 @@ export class SkillInstaller {
51
146
  if (!skill || !skill.installed) return;
52
147
 
53
148
  const dstDir = path.join(this.workspaceSkillsDir, skill.name);
54
- if (fs.existsSync(dstDir) && fs.existsSync(skill.dirPath)) {
55
- fs.cpSync(skill.dirPath, dstDir, { recursive: true });
149
+ if (fs.existsSync(skill.dirPath)) {
150
+ this.cleanSync(skill.dirPath, dstDir);
56
151
  this.ctx.log.info(`Skill synced: "${skill.name}" v${skill.version} → workspace`);
57
152
  }
58
153
  }
154
+
155
+ private cleanSync(srcDir: string, dstDir: string): void {
156
+ if (fs.existsSync(dstDir)) {
157
+ fs.rmSync(dstDir, { recursive: true });
158
+ }
159
+ fs.mkdirSync(dstDir, { recursive: true });
160
+ fs.cpSync(srcDir, dstDir, { recursive: true });
161
+ }
59
162
  }
@@ -91,18 +91,30 @@ export class SkillUpgrader {
91
91
  return { upgraded: false, qualityScore: null };
92
92
  }
93
93
 
94
+ const backupDir = skill.dirPath + ".backup-" + Date.now();
95
+ try { fs.cpSync(skill.dirPath, backupDir, { recursive: true }); } catch { /* best-effort */ }
96
+
94
97
  fs.writeFileSync(path.join(skill.dirPath, "SKILL.md"), newContent, "utf-8");
95
98
 
99
+ await this.rebuildCompanionFiles(skill, newContent, task);
100
+
96
101
  const validation = await this.validator.validate(skill.dirPath, {
97
102
  previousContent: currentContent,
98
103
  });
99
104
 
100
105
  if (!validation.valid) {
101
106
  this.ctx.log.warn(`SkillUpgrader: validation failed for "${skill.name}", reverting: ${validation.errors.join("; ")}`);
102
- fs.writeFileSync(path.join(skill.dirPath, "SKILL.md"), currentContent, "utf-8");
107
+ if (fs.existsSync(backupDir)) {
108
+ fs.rmSync(skill.dirPath, { recursive: true });
109
+ fs.renameSync(backupDir, skill.dirPath);
110
+ } else {
111
+ fs.writeFileSync(path.join(skill.dirPath, "SKILL.md"), currentContent, "utf-8");
112
+ }
103
113
  return { upgraded: false, qualityScore: null };
104
114
  }
105
115
 
116
+ try { if (fs.existsSync(backupDir)) fs.rmSync(backupDir, { recursive: true }); } catch { /* cleanup */ }
117
+
106
118
  const newVersion = skill.version + 1;
107
119
  const newDescription = this.parseDescription(newContent) || skill.description;
108
120
 
@@ -216,4 +228,130 @@ export class SkillUpgrader {
216
228
  if (match2) return match2[1];
217
229
  return "";
218
230
  }
231
+
232
+ private async rebuildCompanionFiles(skill: Skill, newContent: string, task: Task): Promise<void> {
233
+ const chain = buildSkillConfigChain(this.ctx);
234
+ if (chain.length === 0) return;
235
+
236
+ const chunks = this.store.getChunksByTask(task.id);
237
+ const conversationText = chunks
238
+ .filter(c => c.role === "user" || c.role === "assistant" || c.role === "tool")
239
+ .map(c => `[${c.role === "user" ? "User" : c.role === "assistant" ? "Assistant" : "Tool"}]: ${c.content.slice(0, 500)}`)
240
+ .join("\n\n")
241
+ .slice(0, 6000);
242
+
243
+ const scriptsPrompt = `Based on the following upgraded SKILL.md and task record, extract reusable automation scripts.
244
+ Rules:
245
+ - Only extract if the task record contains concrete shell commands, Python scripts, or TypeScript code that form a complete, reusable automation.
246
+ - Each script must be self-contained and runnable.
247
+ - If there are no automatable scripts, return an empty array.
248
+ - Don't fabricate scripts — only extract what was actually used.
249
+
250
+ SKILL.md:
251
+ ${newContent.slice(0, 4000)}
252
+
253
+ Task conversation highlights:
254
+ ${conversationText}
255
+
256
+ Reply with a JSON array only:
257
+ [{"filename": "deploy.sh", "content": "#!/bin/bash\\n..."}]
258
+ If no scripts, reply with: []`;
259
+
260
+ try {
261
+ const raw = await callLLMWithFallback(chain, scriptsPrompt, this.ctx.log, "SkillUpgrader.scripts", {
262
+ maxTokens: 3000, temperature: 0.1, timeoutMs: 60_000, openclawAPI: this.ctx.openclawAPI,
263
+ });
264
+ const scripts = this.parseJSONArray<{ filename: string; content: string }>(raw);
265
+
266
+ const scriptsDir = path.join(skill.dirPath, "scripts");
267
+ if (fs.existsSync(scriptsDir)) fs.rmSync(scriptsDir, { recursive: true });
268
+ if (scripts.length > 0) {
269
+ fs.mkdirSync(scriptsDir, { recursive: true });
270
+ for (const s of scripts) {
271
+ fs.writeFileSync(path.join(scriptsDir, s.filename), s.content, "utf-8");
272
+ }
273
+ this.ctx.log.info(`SkillUpgrader: rebuilt ${scripts.length} scripts for "${skill.name}"`);
274
+ }
275
+ } catch (err) {
276
+ this.ctx.log.warn(`SkillUpgrader: companion scripts rebuild failed: ${err}`);
277
+ }
278
+
279
+ try {
280
+ const evalsPrompt = `Based on the following skill, generate 3-4 realistic test prompts that should trigger this skill.
281
+ Requirements:
282
+ - Write test prompts that a real user would type, mix direct and indirect phrasings
283
+ - LANGUAGE RULE: Write in the SAME language as the skill content.
284
+
285
+ Skill:
286
+ ${newContent.slice(0, 4000)}
287
+
288
+ Reply with a JSON array only:
289
+ [{"id": 1, "prompt": "A realistic user message", "expectations": ["Expected behavior 1"], "trigger_confidence": "high"}]`;
290
+
291
+ const raw = await callLLMWithFallback(chain, evalsPrompt, this.ctx.log, "SkillUpgrader.evals", {
292
+ maxTokens: 2000, temperature: 0.3, timeoutMs: 60_000, openclawAPI: this.ctx.openclawAPI,
293
+ });
294
+ const evals = this.parseJSONArray<{ id: number; prompt: string; expectations: string[] }>(raw);
295
+
296
+ const evalsDir = path.join(skill.dirPath, "evals");
297
+ if (fs.existsSync(evalsDir)) fs.rmSync(evalsDir, { recursive: true });
298
+ if (evals.length > 0) {
299
+ fs.mkdirSync(evalsDir, { recursive: true });
300
+ fs.writeFileSync(
301
+ path.join(evalsDir, "evals.json"),
302
+ JSON.stringify({ skill_name: skill.name, evals }, null, 2),
303
+ "utf-8",
304
+ );
305
+ this.ctx.log.info(`SkillUpgrader: rebuilt ${evals.length} evals for "${skill.name}"`);
306
+ }
307
+ } catch (err) {
308
+ this.ctx.log.warn(`SkillUpgrader: companion evals rebuild failed: ${err}`);
309
+ }
310
+
311
+ try {
312
+ const refsPrompt = `Based on the following upgraded SKILL.md and task record, extract reference documentation worth preserving.
313
+ Rules:
314
+ - Only extract real reference content that appeared in the task (API docs, config examples, architecture notes).
315
+ - Each reference should be a standalone document useful for understanding the skill's domain.
316
+ - If there are no meaningful references, return an empty array.
317
+ - Don't fabricate content — only extract what was actually discussed or used.
318
+ - LANGUAGE RULE: Write in the SAME language as the skill content.
319
+
320
+ SKILL.md:
321
+ ${newContent.slice(0, 4000)}
322
+
323
+ Task conversation highlights:
324
+ ${conversationText}
325
+
326
+ Reply with a JSON array only:
327
+ [{"filename": "api-notes.md", "content": "# API Reference\\n..."}]
328
+ If no references, reply with: []`;
329
+
330
+ const raw = await callLLMWithFallback(chain, refsPrompt, this.ctx.log, "SkillUpgrader.references", {
331
+ maxTokens: 3000, temperature: 0.1, timeoutMs: 60_000, openclawAPI: this.ctx.openclawAPI,
332
+ });
333
+ const refs = this.parseJSONArray<{ filename: string; content: string }>(raw);
334
+
335
+ const refsDir = path.join(skill.dirPath, "references");
336
+ if (fs.existsSync(refsDir)) fs.rmSync(refsDir, { recursive: true });
337
+ if (refs.length > 0) {
338
+ fs.mkdirSync(refsDir, { recursive: true });
339
+ for (const r of refs) {
340
+ fs.writeFileSync(path.join(refsDir, r.filename), r.content, "utf-8");
341
+ }
342
+ this.ctx.log.info(`SkillUpgrader: rebuilt ${refs.length} references for "${skill.name}"`);
343
+ }
344
+ } catch (err) {
345
+ this.ctx.log.warn(`SkillUpgrader: companion references rebuild failed: ${err}`);
346
+ }
347
+ }
348
+
349
+ private parseJSONArray<T>(raw: string): T[] {
350
+ const match = raw.match(/\[[\s\S]*\]/);
351
+ if (!match) return [];
352
+ try {
353
+ const arr = JSON.parse(match[0]);
354
+ return Array.isArray(arr) ? arr : [];
355
+ } catch { return []; }
356
+ }
219
357
  }
@@ -31,6 +31,9 @@ export class SkillValidator {
31
31
  this.validateFormat(dirPath, result);
32
32
  if (!result.valid) return result;
33
33
 
34
+ this.checkCompanionConsistency(dirPath, result);
35
+ this.scanSecrets(dirPath, result);
36
+
34
37
  if (opts?.previousContent) {
35
38
  this.regressionCheck(dirPath, opts.previousContent, result);
36
39
  }
@@ -133,6 +136,82 @@ export class SkillValidator {
133
136
  }
134
137
  }
135
138
 
139
+ private checkCompanionConsistency(dirPath: string, result: ValidationResult): void {
140
+ const skillMdPath = path.join(dirPath, "SKILL.md");
141
+ const content = fs.readFileSync(skillMdPath, "utf-8");
142
+
143
+ const referencedScripts = [...content.matchAll(/`scripts\/([^`]+)`/g)].map(m => m[1]);
144
+ const referencedRefs = [...content.matchAll(/`references\/([^`]+)`/g)].map(m => m[1]);
145
+
146
+ const scriptsDir = path.join(dirPath, "scripts");
147
+ const refsDir = path.join(dirPath, "references");
148
+
149
+ for (const f of referencedScripts) {
150
+ if (!fs.existsSync(path.join(scriptsDir, f))) {
151
+ result.warnings.push(`SKILL.md references scripts/${f} but file does not exist`);
152
+ }
153
+ }
154
+ for (const f of referencedRefs) {
155
+ if (!fs.existsSync(path.join(refsDir, f))) {
156
+ result.warnings.push(`SKILL.md references references/${f} but file does not exist`);
157
+ }
158
+ }
159
+
160
+ if (fs.existsSync(scriptsDir)) {
161
+ try {
162
+ const actualScripts = fs.readdirSync(scriptsDir);
163
+ for (const f of actualScripts) {
164
+ if (!referencedScripts.includes(f)) {
165
+ result.warnings.push(`scripts/${f} exists but is not referenced in SKILL.md`);
166
+ }
167
+ }
168
+ } catch { /* best-effort */ }
169
+ }
170
+
171
+ const evalsPath = path.join(dirPath, "evals", "evals.json");
172
+ if (fs.existsSync(evalsPath)) {
173
+ try {
174
+ const evalsData = JSON.parse(fs.readFileSync(evalsPath, "utf-8"));
175
+ if (!Array.isArray(evalsData?.evals) && !Array.isArray(evalsData)) {
176
+ result.warnings.push("evals/evals.json exists but has unexpected structure");
177
+ }
178
+ } catch {
179
+ result.warnings.push("evals/evals.json exists but is not valid JSON");
180
+ }
181
+ }
182
+ }
183
+
184
+ private static readonly SECRET_PATTERNS: Array<{ label: string; regex: RegExp }> = [
185
+ { label: "API key (sk-...)", regex: /\bsk-[a-zA-Z0-9]{20,}\b/ },
186
+ { label: "Bearer token", regex: /\bBearer\s+[a-zA-Z0-9_\-.]{20,}\b/ },
187
+ { label: "AWS key", regex: /\bAKIA[0-9A-Z]{16}\b/ },
188
+ { label: "Generic secret assignment", regex: /(api[_-]?key|secret|token|password|credential)\s*[:=]\s*["'][^"']{8,}["']/i },
189
+ { label: "Base64 encoded secret (long)", regex: /\b[A-Za-z0-9+/]{40,}={0,2}\b/ },
190
+ ];
191
+
192
+ private scanSecrets(dirPath: string, result: ValidationResult): void {
193
+ const filesToScan = ["SKILL.md"];
194
+ const scriptsDir = path.join(dirPath, "scripts");
195
+ if (fs.existsSync(scriptsDir)) {
196
+ try {
197
+ for (const f of fs.readdirSync(scriptsDir)) filesToScan.push(path.join("scripts", f));
198
+ } catch { /* best-effort */ }
199
+ }
200
+
201
+ for (const relPath of filesToScan) {
202
+ const fullPath = path.join(dirPath, relPath);
203
+ if (!fs.existsSync(fullPath)) continue;
204
+ try {
205
+ const content = fs.readFileSync(fullPath, "utf-8");
206
+ for (const { label, regex } of SkillValidator.SECRET_PATTERNS) {
207
+ if (regex.test(content)) {
208
+ result.warnings.push(`Potential secret detected in ${relPath}: ${label}`);
209
+ }
210
+ }
211
+ } catch { /* best-effort */ }
212
+ }
213
+ }
214
+
136
215
  private async assessQuality(dirPath: string, result: ValidationResult): Promise<void> {
137
216
  const chain = buildSkillConfigChain(this.ctx);
138
217
  if (chain.length === 0) return;