@memtensor/memos-local-openclaw-plugin 0.3.19 → 1.0.0

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 (106) hide show
  1. package/README.md +232 -22
  2. package/dist/capture/index.d.ts +1 -1
  3. package/dist/capture/index.d.ts.map +1 -1
  4. package/dist/capture/index.js +33 -8
  5. package/dist/capture/index.js.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/ingest/providers/anthropic.d.ts.map +1 -1
  11. package/dist/ingest/providers/anthropic.js +22 -8
  12. package/dist/ingest/providers/anthropic.js.map +1 -1
  13. package/dist/ingest/providers/bedrock.d.ts.map +1 -1
  14. package/dist/ingest/providers/bedrock.js +22 -8
  15. package/dist/ingest/providers/bedrock.js.map +1 -1
  16. package/dist/ingest/providers/gemini.d.ts.map +1 -1
  17. package/dist/ingest/providers/gemini.js +22 -8
  18. package/dist/ingest/providers/gemini.js.map +1 -1
  19. package/dist/ingest/providers/index.d.ts +13 -18
  20. package/dist/ingest/providers/index.d.ts.map +1 -1
  21. package/dist/ingest/providers/index.js +213 -139
  22. package/dist/ingest/providers/index.js.map +1 -1
  23. package/dist/ingest/providers/openai.d.ts +1 -1
  24. package/dist/ingest/providers/openai.d.ts.map +1 -1
  25. package/dist/ingest/providers/openai.js +37 -17
  26. package/dist/ingest/providers/openai.js.map +1 -1
  27. package/dist/ingest/task-processor.d.ts +28 -3
  28. package/dist/ingest/task-processor.d.ts.map +1 -1
  29. package/dist/ingest/task-processor.js +166 -67
  30. package/dist/ingest/task-processor.js.map +1 -1
  31. package/dist/ingest/worker.d.ts.map +1 -1
  32. package/dist/ingest/worker.js +97 -75
  33. package/dist/ingest/worker.js.map +1 -1
  34. package/dist/shared/llm-call.d.ts +26 -0
  35. package/dist/shared/llm-call.d.ts.map +1 -0
  36. package/dist/shared/llm-call.js +163 -0
  37. package/dist/shared/llm-call.js.map +1 -0
  38. package/dist/skill/evaluator.d.ts +0 -3
  39. package/dist/skill/evaluator.d.ts.map +1 -1
  40. package/dist/skill/evaluator.js +34 -59
  41. package/dist/skill/evaluator.js.map +1 -1
  42. package/dist/skill/evolver.d.ts +22 -1
  43. package/dist/skill/evolver.d.ts.map +1 -1
  44. package/dist/skill/evolver.js +191 -32
  45. package/dist/skill/evolver.js.map +1 -1
  46. package/dist/skill/generator.d.ts +0 -3
  47. package/dist/skill/generator.d.ts.map +1 -1
  48. package/dist/skill/generator.js +15 -50
  49. package/dist/skill/generator.js.map +1 -1
  50. package/dist/skill/upgrader.d.ts +0 -2
  51. package/dist/skill/upgrader.d.ts.map +1 -1
  52. package/dist/skill/upgrader.js +4 -39
  53. package/dist/skill/upgrader.js.map +1 -1
  54. package/dist/skill/validator.d.ts +0 -2
  55. package/dist/skill/validator.d.ts.map +1 -1
  56. package/dist/skill/validator.js +14 -44
  57. package/dist/skill/validator.js.map +1 -1
  58. package/dist/storage/sqlite.d.ts +13 -2
  59. package/dist/storage/sqlite.d.ts.map +1 -1
  60. package/dist/storage/sqlite.js +72 -6
  61. package/dist/storage/sqlite.js.map +1 -1
  62. package/dist/tools/memory-get.d.ts.map +1 -1
  63. package/dist/tools/memory-get.js +5 -1
  64. package/dist/tools/memory-get.js.map +1 -1
  65. package/dist/tools/memory-search.d.ts.map +1 -1
  66. package/dist/tools/memory-search.js +5 -0
  67. package/dist/tools/memory-search.js.map +1 -1
  68. package/dist/tools/memory-timeline.d.ts.map +1 -1
  69. package/dist/tools/memory-timeline.js +11 -2
  70. package/dist/tools/memory-timeline.js.map +1 -1
  71. package/dist/types.d.ts +2 -1
  72. package/dist/types.d.ts.map +1 -1
  73. package/dist/types.js +1 -1
  74. package/dist/types.js.map +1 -1
  75. package/dist/viewer/html.d.ts +1 -1
  76. package/dist/viewer/html.d.ts.map +1 -1
  77. package/dist/viewer/html.js +233 -9
  78. package/dist/viewer/html.js.map +1 -1
  79. package/dist/viewer/server.d.ts +5 -0
  80. package/dist/viewer/server.d.ts.map +1 -1
  81. package/dist/viewer/server.js +383 -177
  82. package/dist/viewer/server.js.map +1 -1
  83. package/index.ts +26 -4
  84. package/package.json +2 -1
  85. package/src/capture/index.ts +39 -10
  86. package/src/index.ts +3 -2
  87. package/src/ingest/providers/anthropic.ts +22 -8
  88. package/src/ingest/providers/bedrock.ts +22 -8
  89. package/src/ingest/providers/gemini.ts +22 -8
  90. package/src/ingest/providers/index.ts +192 -142
  91. package/src/ingest/providers/openai.ts +37 -17
  92. package/src/ingest/task-processor.ts +183 -65
  93. package/src/ingest/worker.ts +98 -77
  94. package/src/shared/llm-call.ts +144 -0
  95. package/src/skill/evaluator.ts +35 -64
  96. package/src/skill/evolver.ts +201 -33
  97. package/src/skill/generator.ts +16 -59
  98. package/src/skill/upgrader.ts +5 -43
  99. package/src/skill/validator.ts +15 -47
  100. package/src/storage/sqlite.ts +88 -6
  101. package/src/tools/memory-get.ts +6 -1
  102. package/src/tools/memory-search.ts +6 -0
  103. package/src/tools/memory-timeline.ts +13 -1
  104. package/src/types.ts +2 -1
  105. package/src/viewer/html.ts +233 -9
  106. package/src/viewer/server.ts +368 -187
@@ -0,0 +1,144 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import type { SummarizerConfig, Logger, PluginContext } from "../types";
4
+
5
+ /**
6
+ * Build a SummarizerConfig from OpenClaw's native model configuration (openclaw.json).
7
+ * Final fallback when both strongCfg and plugin summarizer fail or are absent.
8
+ */
9
+ export function loadOpenClawFallbackConfig(log: Logger): SummarizerConfig | undefined {
10
+ try {
11
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
12
+ const cfgPath = path.join(home, ".openclaw", "openclaw.json");
13
+ if (!fs.existsSync(cfgPath)) return undefined;
14
+
15
+ const raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
16
+
17
+ const agentModel: string | undefined = raw?.agents?.defaults?.model?.primary;
18
+ if (!agentModel) return undefined;
19
+
20
+ const [providerKey, modelId] = agentModel.includes("/")
21
+ ? agentModel.split("/", 2)
22
+ : [undefined, agentModel];
23
+
24
+ const providerCfg = providerKey
25
+ ? raw?.models?.providers?.[providerKey]
26
+ : Object.values(raw?.models?.providers ?? {})[0] as any;
27
+ if (!providerCfg) return undefined;
28
+
29
+ const baseUrl: string | undefined = providerCfg.baseUrl;
30
+ const apiKey: string | undefined = providerCfg.apiKey;
31
+ if (!baseUrl || !apiKey) return undefined;
32
+
33
+ const endpoint = baseUrl.endsWith("/chat/completions")
34
+ ? baseUrl
35
+ : baseUrl.replace(/\/+$/, "") + "/chat/completions";
36
+
37
+ log.debug(`OpenClaw fallback model: ${modelId} via ${baseUrl}`);
38
+ return {
39
+ provider: "openai_compatible",
40
+ endpoint,
41
+ apiKey,
42
+ model: modelId,
43
+ };
44
+ } catch (err) {
45
+ log.debug(`Failed to load OpenClaw fallback config: ${err}`);
46
+ return undefined;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Build the ordered fallback chain for skill-related LLM calls:
52
+ * skillEvolution.summarizer → plugin summarizer → OpenClaw native model
53
+ */
54
+ export function buildSkillConfigChain(ctx: PluginContext): SummarizerConfig[] {
55
+ const chain: SummarizerConfig[] = [];
56
+ const skillCfg = ctx.config.skillEvolution?.summarizer;
57
+ const pluginCfg = ctx.config.summarizer;
58
+ const fallbackCfg = loadOpenClawFallbackConfig(ctx.log);
59
+ if (skillCfg) chain.push(skillCfg);
60
+ if (pluginCfg && pluginCfg !== skillCfg) chain.push(pluginCfg);
61
+ if (fallbackCfg) chain.push(fallbackCfg);
62
+ return chain;
63
+ }
64
+
65
+ export interface LLMCallOptions {
66
+ maxTokens?: number;
67
+ temperature?: number;
68
+ timeoutMs?: number;
69
+ }
70
+
71
+ function normalizeEndpoint(url: string): string {
72
+ const stripped = url.replace(/\/+$/, "");
73
+ if (stripped.endsWith("/chat/completions")) return stripped;
74
+ if (stripped.endsWith("/completions")) return stripped;
75
+ return `${stripped}/chat/completions`;
76
+ }
77
+
78
+ /**
79
+ * Make a single LLM call with the given config. Throws on failure.
80
+ */
81
+ export async function callLLMOnce(
82
+ cfg: SummarizerConfig,
83
+ prompt: string,
84
+ opts: LLMCallOptions = {},
85
+ ): Promise<string> {
86
+ const endpoint = normalizeEndpoint(cfg.endpoint ?? "https://api.openai.com/v1/chat/completions");
87
+ const model = cfg.model ?? "gpt-4o-mini";
88
+ const headers: Record<string, string> = {
89
+ "Content-Type": "application/json",
90
+ Authorization: `Bearer ${cfg.apiKey}`,
91
+ ...cfg.headers,
92
+ };
93
+
94
+ const resp = await fetch(endpoint, {
95
+ method: "POST",
96
+ headers,
97
+ body: JSON.stringify({
98
+ model,
99
+ temperature: opts.temperature ?? 0.1,
100
+ max_tokens: opts.maxTokens ?? 1024,
101
+ messages: [{ role: "user", content: prompt }],
102
+ }),
103
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 30_000),
104
+ });
105
+
106
+ if (!resp.ok) {
107
+ const body = await resp.text();
108
+ throw new Error(`LLM call failed (${resp.status}): ${body}`);
109
+ }
110
+
111
+ const json = (await resp.json()) as { choices: Array<{ message: { content: string } }> };
112
+ return json.choices[0]?.message?.content?.trim() ?? "";
113
+ }
114
+
115
+ /**
116
+ * Call LLM with fallback chain: tries each config in order until one succeeds.
117
+ * Returns the result string, or throws if ALL configs fail.
118
+ */
119
+ export async function callLLMWithFallback(
120
+ chain: SummarizerConfig[],
121
+ prompt: string,
122
+ log: Logger,
123
+ label: string,
124
+ opts: LLMCallOptions = {},
125
+ ): Promise<string> {
126
+ if (chain.length === 0) {
127
+ throw new Error(`${label}: no LLM config available`);
128
+ }
129
+
130
+ for (let i = 0; i < chain.length; i++) {
131
+ try {
132
+ return await callLLMOnce(chain[i], prompt, opts);
133
+ } catch (err) {
134
+ const modelInfo = `${chain[i].provider ?? "?"}/${chain[i].model ?? "?"}`;
135
+ if (i < chain.length - 1) {
136
+ log.warn(`${label} failed (${modelInfo}), trying next fallback: ${err}`);
137
+ } else {
138
+ log.error(`${label} failed (${modelInfo}), no more fallbacks: ${err}`);
139
+ throw err;
140
+ }
141
+ }
142
+ }
143
+ throw new Error(`${label}: all models failed`);
144
+ }
@@ -1,5 +1,6 @@
1
- import type { Chunk, Task, Skill, PluginContext, SummarizerConfig } from "../types";
1
+ import type { Chunk, Task, Skill, PluginContext } from "../types";
2
2
  import { DEFAULTS } from "../types";
3
+ import { buildSkillConfigChain, callLLMWithFallback } from "../shared/llm-call";
3
4
 
4
5
  export interface CreateEvalResult {
5
6
  shouldGenerate: boolean;
@@ -18,34 +19,45 @@ export interface UpgradeEvalResult {
18
19
  confidence: number;
19
20
  }
20
21
 
21
- const CREATE_EVAL_PROMPT = `You are an experience evaluation expert. Based on the completed task record below, decide whether this task contains reusable experience worth distilling into a "skill".
22
+ const CREATE_EVAL_PROMPT = `You are a strict experience evaluation expert. Based on the completed task record below, decide whether this task contains **reusable, transferable** experience worth distilling into a "skill".
22
23
 
23
- A skill is a reusable guide that helps an AI agent handle similar tasks better in the future.
24
+ A skill is a reusable guide that helps an AI agent handle **the same type of task** better in the future. The key question is: "Will someone likely need to do this exact type of thing again?"
24
25
 
25
- Worth distilling (any ONE qualifies):
26
- - Contains concrete steps, commands, code, or configuration
27
- - Solves a recurring problem with a specific approach/workflow
28
- - Went through trial-and-error (wrong approach then corrected)
26
+ STRICT criteria must meet ALL of:
27
+ 1. **Repeatable**: The task type is likely to recur (not a one-off personal conversation)
28
+ 2. **Transferable**: The approach/solution would help others facing the same problem
29
+ 3. **Technical depth**: Contains non-trivial steps, commands, code, configs, or diagnostic reasoning
30
+
31
+ Worth distilling (must meet criteria above AND at least ONE below):
32
+ - Solves a recurring technical problem with a specific approach/workflow
33
+ - Went through trial-and-error (wrong approach then corrected) — the learning is valuable
29
34
  - Involves non-obvious usage of specific tools, APIs, or frameworks
30
35
  - Contains debugging/troubleshooting with diagnostic reasoning
31
- - Demonstrates a multi-step workflow using external tools (browser, search, file system, etc.)
32
- - Reveals user preferences or style requirements that should be remembered
33
- - Shows how to combine multiple tools/services to accomplish a goal
34
- - Contains a process that required specific parameter tuning or configuration
36
+ - Shows how to combine multiple tools/services to accomplish a technical goal
37
+ - Contains deployment, configuration, or infrastructure setup steps
38
+ - Demonstrates a reusable data processing or automation pipeline
35
39
 
36
- NOT worth distilling:
40
+ NOT worth distilling (if ANY matches, return shouldGenerate=false):
37
41
  - Pure factual Q&A with no process ("what is TCP", "what's the capital of France")
38
42
  - Single-turn simple answers with no workflow
39
43
  - Conversation too fragmented or incoherent to extract a clear process
44
+ - One-off personal tasks: identity confirmation, preference setting, self-introduction
45
+ - Casual chat, opinion discussion, news commentary, brainstorming without actionable output
46
+ - Simple information lookup or summarization (e.g. "summarize this article", "explain X concept")
47
+ - Organizing/listing personal information (work history, resume, contacts)
48
+ - Generic product/system overviews without specific operational steps
49
+ - Tasks where the "steps" are just the AI answering questions (no real workflow)
40
50
 
41
51
  Task title: {TITLE}
42
52
  Task summary:
43
53
  {SUMMARY}
44
54
 
55
+ LANGUAGE RULE: The "reason" field MUST use the SAME language as the task title/summary. Chinese input → Chinese reason. English input → English reason. "suggestedName" stays in English kebab-case.
56
+
45
57
  Reply in JSON only, no extra text:
46
58
  {
47
59
  "shouldGenerate": boolean,
48
- "reason": "brief explanation",
60
+ "reason": "brief explanation (same language as input)",
49
61
  "suggestedName": "kebab-case-name",
50
62
  "suggestedTags": ["tag1", "tag2"],
51
63
  "confidence": 0.0-1.0
@@ -80,13 +92,15 @@ NOT worth upgrading:
80
92
  - New task's approach is worse than existing skill
81
93
  - Differences are trivial
82
94
 
95
+ LANGUAGE RULE: "reason" and "mergeStrategy" MUST use the SAME language as the task title/summary. Chinese input → Chinese output. English input → English output.
96
+
83
97
  Reply in JSON only, no extra text:
84
98
  {
85
99
  "shouldUpgrade": boolean,
86
100
  "upgradeType": "refine" | "extend" | "fix",
87
101
  "dimensions": ["faster", "more_elegant", "more_convenient", "fewer_tokens", "more_accurate", "more_robust", "new_scenario", "fix_outdated"],
88
- "reason": "what new value the task brings",
89
- "mergeStrategy": "which specific parts need updating",
102
+ "reason": "what new value the task brings (same language as input)",
103
+ "mergeStrategy": "which specific parts need updating (same language as input)",
90
104
  "confidence": 0.0-1.0
91
105
  }`;
92
106
 
@@ -121,8 +135,8 @@ export class SkillEvaluator {
121
135
  }
122
136
 
123
137
  async evaluateCreate(task: Task): Promise<CreateEvalResult> {
124
- const cfg = this.getProviderConfig();
125
- if (!cfg) {
138
+ const chain = buildSkillConfigChain(this.ctx);
139
+ if (chain.length === 0) {
126
140
  return { shouldGenerate: false, reason: "no LLM configured", suggestedName: "", suggestedTags: [], confidence: 0 };
127
141
  }
128
142
 
@@ -131,7 +145,7 @@ export class SkillEvaluator {
131
145
  .replace("{SUMMARY}", task.summary.slice(0, 3000));
132
146
 
133
147
  try {
134
- const raw = await this.callLLM(cfg, prompt);
148
+ const raw = await callLLMWithFallback(chain, prompt, this.ctx.log, "SkillEvaluator.create");
135
149
  return this.parseJSON<CreateEvalResult>(raw, {
136
150
  shouldGenerate: false, reason: "parse failed", suggestedName: "", suggestedTags: [], confidence: 0,
137
151
  });
@@ -142,8 +156,8 @@ export class SkillEvaluator {
142
156
  }
143
157
 
144
158
  async evaluateUpgrade(task: Task, skill: Skill, skillContent: string): Promise<UpgradeEvalResult> {
145
- const cfg = this.getProviderConfig();
146
- if (!cfg) {
159
+ const chain = buildSkillConfigChain(this.ctx);
160
+ if (chain.length === 0) {
147
161
  return { shouldUpgrade: false, upgradeType: "refine", dimensions: [], reason: "no LLM configured", mergeStrategy: "", confidence: 0 };
148
162
  }
149
163
 
@@ -155,7 +169,7 @@ export class SkillEvaluator {
155
169
  .replace("{SUMMARY}", task.summary.slice(0, 3000));
156
170
 
157
171
  try {
158
- const raw = await this.callLLM(cfg, prompt);
172
+ const raw = await callLLMWithFallback(chain, prompt, this.ctx.log, "SkillEvaluator.upgrade");
159
173
  return this.parseJSON<UpgradeEvalResult>(raw, {
160
174
  shouldUpgrade: false, upgradeType: "refine", dimensions: [], reason: "parse failed", mergeStrategy: "", confidence: 0,
161
175
  });
@@ -165,42 +179,6 @@ export class SkillEvaluator {
165
179
  }
166
180
  }
167
181
 
168
- private getProviderConfig(): SummarizerConfig | undefined {
169
- return this.ctx.config.summarizer;
170
- }
171
-
172
- private async callLLM(cfg: SummarizerConfig, userContent: string): Promise<string> {
173
- const endpoint = this.normalizeEndpoint(cfg.endpoint ?? "https://api.openai.com/v1/chat/completions");
174
- const model = cfg.model ?? "gpt-4o-mini";
175
- const headers: Record<string, string> = {
176
- "Content-Type": "application/json",
177
- Authorization: `Bearer ${cfg.apiKey}`,
178
- ...cfg.headers,
179
- };
180
-
181
- const resp = await fetch(endpoint, {
182
- method: "POST",
183
- headers,
184
- body: JSON.stringify({
185
- model,
186
- temperature: cfg.temperature ?? 0.1,
187
- max_tokens: 1024,
188
- messages: [
189
- { role: "user", content: userContent },
190
- ],
191
- }),
192
- signal: AbortSignal.timeout(cfg.timeoutMs ?? 30_000),
193
- });
194
-
195
- if (!resp.ok) {
196
- const body = await resp.text();
197
- throw new Error(`LLM call failed (${resp.status}): ${body}`);
198
- }
199
-
200
- const json = (await resp.json()) as { choices: Array<{ message: { content: string } }> };
201
- return json.choices[0]?.message?.content?.trim() ?? "";
202
- }
203
-
204
182
  private parseJSON<T>(raw: string, fallback: T): T {
205
183
  const jsonMatch = raw.match(/\{[\s\S]*\}/);
206
184
  if (!jsonMatch) return fallback;
@@ -210,11 +188,4 @@ export class SkillEvaluator {
210
188
  return fallback;
211
189
  }
212
190
  }
213
-
214
- private normalizeEndpoint(url: string): string {
215
- const stripped = url.replace(/\/+$/, "");
216
- if (stripped.endsWith("/chat/completions")) return stripped;
217
- if (stripped.endsWith("/completions")) return stripped;
218
- return `${stripped}/chat/completions`;
219
- }
220
191
  }
@@ -3,12 +3,14 @@ import * as path from "path";
3
3
  import type { SqliteStore } from "../storage/sqlite";
4
4
  import type { RecallEngine } from "../recall/engine";
5
5
  import type { Embedder } from "../embedding";
6
+ import { cosineSimilarity } from "../storage/vector";
6
7
  import type { Task, Skill, Chunk, PluginContext } from "../types";
7
8
  import { DEFAULTS } from "../types";
8
9
  import { SkillEvaluator } from "./evaluator";
9
10
  import { SkillGenerator } from "./generator";
10
11
  import { SkillUpgrader } from "./upgrader";
11
12
  import { SkillInstaller } from "./installer";
13
+ import { buildSkillConfigChain, callLLMWithFallback } from "../shared/llm-call";
12
14
 
13
15
  export class SkillEvolver {
14
16
  private evaluator: SkillEvaluator;
@@ -16,12 +18,13 @@ export class SkillEvolver {
16
18
  private upgrader: SkillUpgrader;
17
19
  private installer: SkillInstaller;
18
20
  private processing = false;
21
+ private queue: Task[] = [];
19
22
 
20
23
  constructor(
21
24
  private store: SqliteStore,
22
25
  private engine: RecallEngine,
23
26
  private ctx: PluginContext,
24
- embedder?: Embedder,
27
+ private embedder?: Embedder,
25
28
  ) {
26
29
  this.evaluator = new SkillEvaluator(ctx);
27
30
  this.generator = new SkillGenerator(store, engine, ctx, embedder);
@@ -29,25 +32,57 @@ export class SkillEvolver {
29
32
  this.installer = new SkillInstaller(store, ctx);
30
33
  }
31
34
 
35
+ async recoverOrphanedTasks(): Promise<number> {
36
+ const orphaned = this.store.getTasksBySkillStatus(["queued", "generating"]);
37
+ if (orphaned.length === 0) return 0;
38
+
39
+ this.ctx.log.info(`SkillEvolver: recovering ${orphaned.length} orphaned tasks (queued/generating from previous run)`);
40
+ for (const task of orphaned) {
41
+ try {
42
+ await this.processOne(task);
43
+ } catch (err) {
44
+ this.ctx.log.error(`SkillEvolver: recovery failed for task ${task.id}: ${err}`);
45
+ }
46
+ }
47
+ return orphaned.length;
48
+ }
49
+
32
50
  async onTaskCompleted(task: Task): Promise<void> {
33
51
  const enabled = this.ctx.config.skillEvolution?.enabled ?? DEFAULTS.skillEvolutionEnabled;
34
52
  const autoEval = this.ctx.config.skillEvolution?.autoEvaluate ?? DEFAULTS.skillAutoEvaluate;
35
53
  if (!enabled || !autoEval) return;
36
54
 
37
55
  if (this.processing) {
38
- this.ctx.log.debug("SkillEvolver: already processing, skipping");
56
+ this.ctx.log.debug(`SkillEvolver: busy, queuing task ${task.id} (queue=${this.queue.length})`);
57
+ this.store.setTaskSkillMeta(task.id, { skillStatus: "queued", skillReason: `排队中,前方还有 ${this.queue.length + 1} 个任务` });
58
+ this.queue.push(task);
39
59
  return;
40
60
  }
61
+ await this.drain(task);
62
+ }
63
+
64
+ private async drain(task: Task): Promise<void> {
41
65
  this.processing = true;
42
66
  try {
43
- await this.process(task);
44
- } catch (err) {
45
- this.ctx.log.error(`SkillEvolver error: ${err}`);
67
+ await this.processOne(task);
68
+ while (this.queue.length > 0) {
69
+ const next = this.queue.shift()!;
70
+ await this.processOne(next);
71
+ }
46
72
  } finally {
47
73
  this.processing = false;
48
74
  }
49
75
  }
50
76
 
77
+ private async processOne(task: Task): Promise<void> {
78
+ try {
79
+ await this.process(task);
80
+ } catch (err) {
81
+ this.ctx.log.error(`SkillEvolver error for task ${task.id}: ${err}`);
82
+ this.store.setTaskSkillMeta(task.id, { skillStatus: "skipped", skillReason: `Error: ${err}` });
83
+ }
84
+ }
85
+
51
86
  private async process(task: Task): Promise<void> {
52
87
  const chunks = this.store.getChunksByTask(task.id);
53
88
 
@@ -67,63 +102,196 @@ export class SkillEvolver {
67
102
  }
68
103
  }
69
104
 
105
+ /** Max candidates to send to LLM for relevance judgment. */
106
+ private static readonly RELATED_SKILL_CANDIDATE_TOP = 10;
107
+
108
+ /**
109
+ * Search for an existing skill that is HIGHLY related to the given task.
110
+ *
111
+ * 1. Collect top 50 skill candidates by FTS + vector similarity (relaxed thresholds).
112
+ * 2. Call LLM with task title/summary and each skill's name/description; strict rule:
113
+ * only output ONE skill index if the task clearly belongs to that skill's domain;
114
+ * otherwise output 0 (do not force a match).
115
+ */
70
116
  private async findRelatedSkill(task: Task): Promise<Skill | null> {
117
+ const query = task.summary.slice(0, 600);
118
+ const owner = task.owner ?? "agent:main";
119
+ // Relaxed thresholds to gather a larger candidate pool; LLM will do strict filtering
120
+ const VEC_FLOOR = 0.35;
121
+ const TOP_N = SkillEvolver.RELATED_SKILL_CANDIDATE_TOP;
122
+
123
+ type Candidate = { skill: Skill; vecScore: number; ftsScore: number; combined: number };
124
+ const candidateMap = new Map<string, Candidate>();
125
+
126
+ // 1. FTS on skill name + description (take more candidates)
71
127
  try {
72
- const result = await this.engine.search({
73
- query: task.summary.slice(0, 500),
74
- maxResults: 10,
75
- minScore: 0.5,
76
- });
77
-
78
- for (const hit of result.hits) {
79
- if (hit.skillId) {
80
- const skill = this.store.getSkill(hit.skillId);
81
- if (skill && (skill.status === "active" || skill.status === "draft")) {
82
- this.ctx.log.debug(`SkillEvolver: found related skill "${skill.name}" via memory search`);
83
- return skill;
128
+ const ftsHits = this.store.skillFtsSearch(query, TOP_N, "mix", owner);
129
+ for (const hit of ftsHits) {
130
+ const skill = this.store.getSkill(hit.skillId);
131
+ if (skill && (skill.status === "active" || skill.status === "draft")) {
132
+ candidateMap.set(skill.id, { skill, vecScore: 0, ftsScore: hit.score, combined: 0 });
133
+ }
134
+ }
135
+ } catch (err) {
136
+ this.ctx.log.warn(`SkillEvolver: skill FTS search failed: ${err}`);
137
+ }
138
+
139
+ // 2. Vector similarity: include all skills above a low floor to rank them
140
+ if (this.embedder) {
141
+ try {
142
+ const queryVec = await this.embedder.embedQuery(query);
143
+ const allSkillEmb = this.store.getSkillEmbeddings("mix", owner);
144
+ for (const row of allSkillEmb) {
145
+ const sim = cosineSimilarity(queryVec, row.vector);
146
+ if (sim >= VEC_FLOOR) {
147
+ const existing = candidateMap.get(row.skillId);
148
+ if (existing) {
149
+ existing.vecScore = sim;
150
+ } else {
151
+ const skill = this.store.getSkill(row.skillId);
152
+ if (skill && (skill.status === "active" || skill.status === "draft")) {
153
+ candidateMap.set(skill.id, { skill, vecScore: sim, ftsScore: 0, combined: 0 });
154
+ }
155
+ }
84
156
  }
85
157
  }
158
+ } catch (err) {
159
+ this.ctx.log.warn(`SkillEvolver: skill vector search failed: ${err}`);
86
160
  }
161
+ }
162
+
163
+ if (candidateMap.size === 0) return null;
164
+
165
+ for (const c of candidateMap.values()) {
166
+ c.combined = c.vecScore * 0.7 + c.ftsScore * 0.3;
167
+ }
168
+
169
+ const sorted = [...candidateMap.values()]
170
+ .sort((a, b) => b.combined - a.combined)
171
+ .slice(0, TOP_N);
172
+
173
+ if (sorted.length === 0) return null;
174
+
175
+ // 3. LLM strict relevance judgment: only one skill if HIGHLY related, else none
176
+ const selectedSkill = await this.judgeSkillRelatedToTask(task, sorted);
177
+ if (selectedSkill) {
178
+ this.ctx.log.debug(`SkillEvolver: LLM selected related skill "${selectedSkill.name}" for task "${task.title}"`);
179
+ } else {
180
+ this.ctx.log.debug(`SkillEvolver: LLM found no highly related skill for task "${task.title}" (${sorted.length} candidates)`);
181
+ }
182
+ return selectedSkill;
183
+ }
184
+
185
+ /**
186
+ * Ask LLM to pick at most ONE skill that is HIGHLY relevant to the task.
187
+ * Strict rule: only return a skill if the task clearly belongs to that skill's domain; otherwise return null.
188
+ */
189
+ private async judgeSkillRelatedToTask(
190
+ task: Task,
191
+ candidates: Array<{ skill: Skill; vecScore: number; ftsScore: number; combined: number }>,
192
+ ): Promise<Skill | null> {
193
+ const chain = buildSkillConfigChain(this.ctx);
194
+ if (chain.length === 0) {
195
+ this.ctx.log.warn("SkillEvolver: no LLM config available, skipping skill relevance judgment");
196
+ return null;
197
+ }
198
+
199
+ const taskTitle = task.title || "(no title)";
200
+ const taskSummary = task.summary.slice(0, 800);
201
+ const skillList = candidates
202
+ .map((c, i) => `${i + 1}. [${c.skill.name}]\n ${(c.skill.description || "").slice(0, 300)}`)
203
+ .join("\n\n");
204
+
205
+ const prompt = `You are a strict judge: decide whether a completed TASK should be merged into an EXISTING SKILL. The task and the skill must be in the SAME domain/topic — e.g. same type of problem, same tool, same workflow. Loose or tangential relevance is NOT enough.
206
+
207
+ TASK TITLE: ${taskTitle}
208
+
209
+ TASK SUMMARY:
210
+ ${taskSummary}
211
+
212
+ CANDIDATE SKILLS (index, name, description):
213
+ ${skillList}
214
+
215
+ RULES:
216
+ - Output exactly ONE skill index (1 to ${candidates.length}) ONLY if the task's experience clearly belongs to that skill's domain. Same topic, same kind of work.
217
+ - If no skill is clearly relevant (different domain, or only loosely related), output 0. When in doubt, output 0.
218
+ - Do not force a match. "Movie recommendation" task must not match "Weather query" or "Legal discussion" skill even if both exist in the list.
219
+
220
+ LANGUAGE RULE: "reason" MUST use the SAME language as the task title/summary. Chinese input → Chinese reason.
221
+
222
+ Reply with JSON only, no other text:
223
+ {"selectedIndex": 0, "reason": "brief explanation (same language as input)"}
224
+ Use selectedIndex 0 when none is highly relevant.`;
225
+
226
+ try {
227
+ const raw = await callLLMWithFallback(chain, prompt, this.ctx.log, "SkillEvolver.judgeRelated", { temperature: 0, maxTokens: 256 });
228
+ const parsed = this.parseJudgeSkillResult(raw, candidates.length);
229
+ if (parsed.selectedIndex >= 1 && parsed.selectedIndex <= candidates.length) {
230
+ return candidates[parsed.selectedIndex - 1].skill;
231
+ }
232
+ return null;
87
233
  } catch (err) {
88
- this.ctx.log.warn(`SkillEvolver: memory search for related skill failed: ${err}`);
234
+ this.ctx.log.warn(`SkillEvolver: LLM skill relevance judgment failed: ${err}`);
235
+ return null;
236
+ }
237
+ }
238
+
239
+ private parseJudgeSkillResult(raw: string, maxIndex: number): { selectedIndex: number; reason: string } {
240
+ const fallback = { selectedIndex: 0, reason: "parse failed" };
241
+ const match = raw.match(/\{[\s\S]*\}/);
242
+ if (!match) return fallback;
243
+ try {
244
+ const obj = JSON.parse(match[0]) as { selectedIndex?: number; reason?: string };
245
+ const idx = typeof obj.selectedIndex === "number" ? obj.selectedIndex : 0;
246
+ const reason = typeof obj.reason === "string" ? obj.reason : "";
247
+ if (idx < 0 || idx > maxIndex) return { selectedIndex: 0, reason: reason || "out of range" };
248
+ return { selectedIndex: idx, reason };
249
+ } catch {
250
+ return fallback;
89
251
  }
90
- return null;
91
252
  }
92
253
 
93
254
  private async handleExistingSkill(task: Task, chunks: Chunk[], skill: Skill): Promise<void> {
94
- const skillContent = this.readSkillContent(skill);
255
+ // Verify skill still exists in DB (may have been manually deleted)
256
+ const freshSkill = this.store.getSkill(skill.id);
257
+ if (!freshSkill) {
258
+ this.ctx.log.warn(`SkillEvolver: skill "${skill.name}" (${skill.id}) no longer exists, treating as new`);
259
+ await this.handleNewSkill(task, chunks);
260
+ return;
261
+ }
262
+
263
+ const skillContent = this.readSkillContent(freshSkill);
95
264
  if (!skillContent) {
96
- this.ctx.log.warn(`SkillEvolver: cannot read skill "${skill.name}" content, treating as new`);
265
+ this.ctx.log.warn(`SkillEvolver: cannot read skill "${freshSkill.name}" content, treating as new`);
97
266
  await this.handleNewSkill(task, chunks);
98
267
  return;
99
268
  }
100
269
 
101
270
  const minConfidence = this.ctx.config.skillEvolution?.minConfidence ?? DEFAULTS.skillMinConfidence;
102
- const evalResult = await this.evaluator.evaluateUpgrade(task, skill, skillContent);
271
+ const evalResult = await this.evaluator.evaluateUpgrade(task, freshSkill, skillContent);
103
272
 
104
273
  if (evalResult.shouldUpgrade && evalResult.confidence >= minConfidence) {
105
- this.ctx.log.info(`SkillEvolver: upgrading skill "${skill.name}" — ${evalResult.reason}`);
106
- const { upgraded } = await this.upgrader.upgrade(task, skill, evalResult);
274
+ this.ctx.log.info(`SkillEvolver: upgrading skill "${freshSkill.name}" — ${evalResult.reason}`);
275
+ const { upgraded } = await this.upgrader.upgrade(task, freshSkill, evalResult);
107
276
 
108
- this.markChunksWithSkill(chunks, skill.id);
277
+ this.markChunksWithSkill(chunks, freshSkill.id);
109
278
 
110
279
  if (upgraded) {
111
- this.store.linkTaskSkill(task.id, skill.id, "evolved_from", skill.version + 1);
112
- this.installer.syncIfInstalled(skill.name);
280
+ this.store.linkTaskSkill(task.id, freshSkill.id, "evolved_from", freshSkill.version + 1);
281
+ this.installer.syncIfInstalled(freshSkill.name);
113
282
  } else {
114
- this.store.linkTaskSkill(task.id, skill.id, "applied_to", skill.version);
283
+ this.store.linkTaskSkill(task.id, freshSkill.id, "applied_to", freshSkill.version);
115
284
  }
116
285
  } else if (evalResult.confidence < 0.3) {
117
- // Low confidence means the matched skill is likely unrelated — try creating a new one
118
286
  this.ctx.log.info(
119
- `SkillEvolver: skill "${skill.name}" has low relevance (confidence=${evalResult.confidence}), ` +
287
+ `SkillEvolver: skill "${freshSkill.name}" has low relevance (confidence=${evalResult.confidence}), ` +
120
288
  `falling back to new skill evaluation for task "${task.title}"`,
121
289
  );
122
290
  await this.handleNewSkill(task, chunks);
123
291
  } else {
124
- this.ctx.log.debug(`SkillEvolver: skill "${skill.name}" not worth upgrading (confidence=${evalResult.confidence})`);
125
- this.markChunksWithSkill(chunks, skill.id);
126
- this.store.linkTaskSkill(task.id, skill.id, "applied_to", skill.version);
292
+ this.ctx.log.debug(`SkillEvolver: skill "${freshSkill.name}" not worth upgrading (confidence=${evalResult.confidence})`);
293
+ this.markChunksWithSkill(chunks, freshSkill.id);
294
+ this.store.linkTaskSkill(task.id, freshSkill.id, "applied_to", freshSkill.version);
127
295
  }
128
296
  }
129
297