assistme 0.2.3 → 0.2.4
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.
- package/dist/index.js +4 -2
- package/package.json +1 -1
- package/src/agent/mcp-servers.ts +0 -2
- package/src/agent/processor.test.ts +1 -4
- package/src/agent/skill-extractor.ts +0 -459
- package/src/agent/skills.ts +7 -3
- package/src/agent/memory-extractor.ts +0 -128
- package/src/utils/validation.test.ts +0 -153
- package/src/utils/validation.ts +0 -101
package/dist/index.js
CHANGED
|
@@ -1985,8 +1985,10 @@ _(${skills.length - included} additional skills available \u2014 use skill_invok
|
|
|
1985
1985
|
log.debug(`Skill create returned no data for "${name}"`);
|
|
1986
1986
|
return null;
|
|
1987
1987
|
}
|
|
1988
|
-
|
|
1989
|
-
|
|
1988
|
+
const id = row.out_id || row.id;
|
|
1989
|
+
const skillName = row.out_name || row.name;
|
|
1990
|
+
log.info(`Skill "${skillName}" created in skills table (pending approval)`);
|
|
1991
|
+
return { id, name: skillName };
|
|
1990
1992
|
} catch (err) {
|
|
1991
1993
|
log.debug(`Skill create error: ${err}`);
|
|
1992
1994
|
return null;
|
package/package.json
CHANGED
package/src/agent/mcp-servers.ts
CHANGED
|
@@ -10,8 +10,6 @@ import { log } from "../utils/logger.js";
|
|
|
10
10
|
import type { MemoryManager, MemoryCategory } from "./memory.js";
|
|
11
11
|
import type { SkillManager } from "./skills.js";
|
|
12
12
|
import { substituteArguments, preprocessDynamicContext } from "./skills.js";
|
|
13
|
-
// decomposeJob and generateSkillFromDescription removed — the agent SDK handles
|
|
14
|
-
// job analysis and skill generation directly (no separate LLM API calls needed)
|
|
15
13
|
import { getSupabase } from "../db/supabase.js";
|
|
16
14
|
import { JobRunner } from "./job-runner.js";
|
|
17
15
|
import {
|
|
@@ -92,11 +92,8 @@ vi.mock("./skills.js", () => ({
|
|
|
92
92
|
},
|
|
93
93
|
}));
|
|
94
94
|
|
|
95
|
-
vi.mock("./memory-extractor.js", () => ({
|
|
96
|
-
extractMemoriesWithLLM: vi.fn().mockResolvedValue([]),
|
|
97
|
-
}));
|
|
98
95
|
vi.mock("./skill-extractor.js", () => ({
|
|
99
|
-
// Only ToolCallRecord type is used
|
|
96
|
+
// Only ToolCallRecord type is used
|
|
100
97
|
}));
|
|
101
98
|
|
|
102
99
|
vi.mock("../utils/rate-limiter.js", () => ({
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
import { getConfig } from "../utils/config.js";
|
|
2
|
-
import { log } from "../utils/logger.js";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Extracted skill from LLM analysis of a multi-step task.
|
|
6
|
-
*/
|
|
7
|
-
export interface ExtractedSkill {
|
|
8
|
-
name: string; // kebab-case, e.g. "price-comparison-amazon"
|
|
9
|
-
description: string; // One-line description
|
|
10
|
-
steps: string; // Markdown step-by-step instructions
|
|
11
|
-
emoji: string; // Single emoji for this skill
|
|
12
|
-
}
|
|
13
|
-
|
|
14
1
|
/**
|
|
15
2
|
* A record of a tool call made during task execution.
|
|
16
3
|
*/
|
|
@@ -19,449 +6,3 @@ export interface ToolCallRecord {
|
|
|
19
6
|
input: Record<string, unknown>;
|
|
20
7
|
result: string;
|
|
21
8
|
}
|
|
22
|
-
|
|
23
|
-
const EXTRACTION_PROMPT = `You are a skill extraction system for an AI agent that controls a real web browser (Chrome via CDP) and can read/write files. Your job is to analyze completed task conversations and extract REUSABLE WORKFLOWS as skills.
|
|
24
|
-
|
|
25
|
-
A skill is a step-by-step instruction template that teaches the agent how to handle a TYPE of task (not a specific instance).
|
|
26
|
-
|
|
27
|
-
Rules:
|
|
28
|
-
- Only extract skills for tasks that involved 3+ distinct steps (multi-step workflows)
|
|
29
|
-
- The skill must be GENERALIZABLE — it should work for similar future tasks, not just this specific one
|
|
30
|
-
- Replace specific values (URLs, product names, prices) with generic placeholders like {product}, {website}, {query}
|
|
31
|
-
- Focus on the WORKFLOW PATTERN, not the specific data
|
|
32
|
-
- Write clear markdown instructions that another AI agent could follow
|
|
33
|
-
- If the task was too simple or too specific to generalize, return null
|
|
34
|
-
- The name must be kebab-case (lowercase, hyphens)
|
|
35
|
-
- Description should be one concise line
|
|
36
|
-
|
|
37
|
-
DO NOT extract skills for:
|
|
38
|
-
- Simple single-step tasks (just opening one page)
|
|
39
|
-
- Tasks that failed or had errors
|
|
40
|
-
- Tasks that are too specific to generalize (e.g. "buy exactly this item at this price")
|
|
41
|
-
|
|
42
|
-
Respond with ONLY valid JSON — no markdown wrapping, no explanation:
|
|
43
|
-
|
|
44
|
-
If a reusable skill was found:
|
|
45
|
-
{"name": "skill-name", "description": "What this skill does", "steps": "## Workflow\\n\\n1. **Step one**\\n...", "emoji": "🔧"}
|
|
46
|
-
|
|
47
|
-
If nothing worth extracting:
|
|
48
|
-
null`;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Use Haiku to analyze a completed multi-step task and extract a reusable skill.
|
|
52
|
-
* Only called when a task involved 3+ tool calls (multi-step workflow).
|
|
53
|
-
*
|
|
54
|
-
* Cost: ~$0.002 per extraction (slightly more context than memory extraction).
|
|
55
|
-
*/
|
|
56
|
-
export async function extractSkillWithLLM(
|
|
57
|
-
taskPrompt: string,
|
|
58
|
-
taskResult: string,
|
|
59
|
-
toolCalls: ToolCallRecord[]
|
|
60
|
-
): Promise<ExtractedSkill | null> {
|
|
61
|
-
const config = getConfig();
|
|
62
|
-
const apiKey = config.anthropicApiKey;
|
|
63
|
-
|
|
64
|
-
if (!apiKey) {
|
|
65
|
-
log.debug("No API key, skipping skill extraction");
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Build a summary of tool calls (not the full data, just the flow)
|
|
70
|
-
const toolFlow = toolCalls
|
|
71
|
-
.map((tc, i) => {
|
|
72
|
-
const inputSummary = Object.entries(tc.input)
|
|
73
|
-
.map(([k, v]) => `${k}=${String(v).slice(0, 100)}`)
|
|
74
|
-
.join(", ");
|
|
75
|
-
const resultSummary = tc.result.slice(0, 150);
|
|
76
|
-
return `${i + 1}. ${tc.name}(${inputSummary}) → ${resultSummary}`;
|
|
77
|
-
})
|
|
78
|
-
.slice(0, 20) // Max 20 tool calls in summary
|
|
79
|
-
.join("\n");
|
|
80
|
-
|
|
81
|
-
const userMessage = `<task_prompt>
|
|
82
|
-
${taskPrompt.slice(0, 1500)}
|
|
83
|
-
</task_prompt>
|
|
84
|
-
|
|
85
|
-
<tool_calls_flow>
|
|
86
|
-
${toolFlow.slice(0, 3000)}
|
|
87
|
-
</tool_calls_flow>
|
|
88
|
-
|
|
89
|
-
<final_result>
|
|
90
|
-
${taskResult.slice(0, 2000)}
|
|
91
|
-
</final_result>`;
|
|
92
|
-
|
|
93
|
-
try {
|
|
94
|
-
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
95
|
-
method: "POST",
|
|
96
|
-
headers: {
|
|
97
|
-
"Content-Type": "application/json",
|
|
98
|
-
"x-api-key": apiKey,
|
|
99
|
-
"anthropic-version": "2023-06-01",
|
|
100
|
-
},
|
|
101
|
-
body: JSON.stringify({
|
|
102
|
-
model: "claude-haiku-4-5-20251001",
|
|
103
|
-
max_tokens: 1024,
|
|
104
|
-
system: EXTRACTION_PROMPT,
|
|
105
|
-
messages: [{ role: "user", content: userMessage }],
|
|
106
|
-
}),
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
if (!response.ok) {
|
|
110
|
-
const errText = await response.text();
|
|
111
|
-
log.debug(
|
|
112
|
-
`Skill extraction API error: ${response.status} ${errText.slice(0, 200)}`
|
|
113
|
-
);
|
|
114
|
-
return null;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const data = await response.json();
|
|
118
|
-
|
|
119
|
-
const text =
|
|
120
|
-
data.content?.[0]?.type === "text" ? data.content[0].text : "";
|
|
121
|
-
|
|
122
|
-
if (!text || text.trim() === "null") {
|
|
123
|
-
log.debug("Skill extraction: nothing reusable found");
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Parse JSON
|
|
128
|
-
const jsonStr = text
|
|
129
|
-
.replace(/```json\n?/g, "")
|
|
130
|
-
.replace(/```\n?/g, "")
|
|
131
|
-
.trim();
|
|
132
|
-
const skill: ExtractedSkill = JSON.parse(jsonStr);
|
|
133
|
-
|
|
134
|
-
// Validate
|
|
135
|
-
if (!skill || !skill.name || !skill.steps || !skill.description) {
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Sanitize name to kebab-case
|
|
140
|
-
skill.name = skill.name
|
|
141
|
-
.toLowerCase()
|
|
142
|
-
.replace(/[^a-z0-9-]/g, "-")
|
|
143
|
-
.replace(/-+/g, "-")
|
|
144
|
-
.replace(/^-|-$/g, "");
|
|
145
|
-
|
|
146
|
-
if (skill.name.length < 3) return null;
|
|
147
|
-
|
|
148
|
-
return skill;
|
|
149
|
-
} catch (err) {
|
|
150
|
-
log.debug(
|
|
151
|
-
`Skill extraction failed: ${err instanceof Error ? err.message : err}`
|
|
152
|
-
);
|
|
153
|
-
return null;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ── Proactive Skill Generation (from description, not from completed tasks) ──
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* A skill specification decomposed from a work profile.
|
|
161
|
-
*/
|
|
162
|
-
export interface SkillSpec {
|
|
163
|
-
name: string;
|
|
164
|
-
description: string;
|
|
165
|
-
category: string; // e.g. "data-processing", "communication", "reporting"
|
|
166
|
-
priority: "high" | "medium" | "low";
|
|
167
|
-
automatable: boolean; // whether the agent can fully automate this
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const DECOMPOSE_JOB_PROMPT = `You are a job analysis system. Given a person's job description, decompose their work into individual AUTOMATABLE SKILLS that an AI agent could learn and execute.
|
|
171
|
-
|
|
172
|
-
The AI agent has these capabilities:
|
|
173
|
-
- Control a real Chrome browser (navigate, click, type, read pages, take screenshots)
|
|
174
|
-
- Read/write files on the local machine
|
|
175
|
-
- Execute shell commands
|
|
176
|
-
- Store memories and learn from interactions
|
|
177
|
-
|
|
178
|
-
Rules:
|
|
179
|
-
- Focus on RECURRING, REPETITIVE tasks that benefit from automation
|
|
180
|
-
- Each skill should be a single, well-defined workflow
|
|
181
|
-
- Prioritize tasks the person does frequently
|
|
182
|
-
- Mark tasks as automatable:true only if the agent can handle them end-to-end
|
|
183
|
-
- Name skills in kebab-case
|
|
184
|
-
- Be specific — "check-email" is too vague, "summarize-unread-gmail" is better
|
|
185
|
-
- Generate 5-15 skills depending on job complexity
|
|
186
|
-
- Consider both web-based and file-based workflows
|
|
187
|
-
|
|
188
|
-
Respond with ONLY valid JSON array — no markdown wrapping:
|
|
189
|
-
[{"name": "skill-name", "description": "What this skill does", "category": "category", "priority": "high|medium|low", "automatable": true|false}]`;
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Decompose a job description into individual skill specifications.
|
|
193
|
-
* This is the first step: understand what the person does and break it into automatable units.
|
|
194
|
-
*
|
|
195
|
-
* Cost: ~$0.003 per call (Haiku, moderate context).
|
|
196
|
-
*/
|
|
197
|
-
export async function decomposeJob(
|
|
198
|
-
jobDescription: string,
|
|
199
|
-
existingSkillNames: string[] = []
|
|
200
|
-
): Promise<SkillSpec[]> {
|
|
201
|
-
const config = getConfig();
|
|
202
|
-
const apiKey = config.anthropicApiKey;
|
|
203
|
-
if (!apiKey) return [];
|
|
204
|
-
|
|
205
|
-
const existingNote =
|
|
206
|
-
existingSkillNames.length > 0
|
|
207
|
-
? `\n\nThe agent already has these skills (do NOT duplicate): ${existingSkillNames.join(", ")}`
|
|
208
|
-
: "";
|
|
209
|
-
|
|
210
|
-
try {
|
|
211
|
-
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
212
|
-
method: "POST",
|
|
213
|
-
headers: {
|
|
214
|
-
"Content-Type": "application/json",
|
|
215
|
-
"x-api-key": apiKey,
|
|
216
|
-
"anthropic-version": "2023-06-01",
|
|
217
|
-
},
|
|
218
|
-
body: JSON.stringify({
|
|
219
|
-
model: "claude-haiku-4-5-20251001",
|
|
220
|
-
max_tokens: 2048,
|
|
221
|
-
system: DECOMPOSE_JOB_PROMPT,
|
|
222
|
-
messages: [
|
|
223
|
-
{
|
|
224
|
-
role: "user",
|
|
225
|
-
content: `<job_description>\n${jobDescription.slice(0, 3000)}\n</job_description>${existingNote}`,
|
|
226
|
-
},
|
|
227
|
-
],
|
|
228
|
-
}),
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
if (!response.ok) {
|
|
232
|
-
const errText = await response.text().catch(() => "");
|
|
233
|
-
log.debug(`decomposeJob API error: ${response.status} ${errText.slice(0, 300)}`);
|
|
234
|
-
return [];
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const data = await response.json();
|
|
238
|
-
const text =
|
|
239
|
-
data.content?.[0]?.type === "text" ? data.content[0].text : "";
|
|
240
|
-
if (!text) {
|
|
241
|
-
log.debug(`decomposeJob: empty response from API`);
|
|
242
|
-
return [];
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const jsonStr = text
|
|
246
|
-
.replace(/```json\n?/g, "")
|
|
247
|
-
.replace(/```\n?/g, "")
|
|
248
|
-
.trim();
|
|
249
|
-
const specs: SkillSpec[] = JSON.parse(jsonStr);
|
|
250
|
-
|
|
251
|
-
if (!Array.isArray(specs)) {
|
|
252
|
-
log.debug(`decomposeJob: response is not an array: ${jsonStr.slice(0, 200)}`);
|
|
253
|
-
return [];
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// Sanitize names
|
|
257
|
-
return specs
|
|
258
|
-
.filter((s) => s.name && s.description)
|
|
259
|
-
.map((s) => ({
|
|
260
|
-
...s,
|
|
261
|
-
name: s.name
|
|
262
|
-
.toLowerCase()
|
|
263
|
-
.replace(/[^a-z0-9-]/g, "-")
|
|
264
|
-
.replace(/-+/g, "-")
|
|
265
|
-
.replace(/^-|-$/g, ""),
|
|
266
|
-
}));
|
|
267
|
-
} catch (err) {
|
|
268
|
-
log.debug(`Job decomposition failed: ${err instanceof Error ? err.message : err}`);
|
|
269
|
-
return [];
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const GENERATE_PROMPT = `You are a skill authoring system for an AI agent. Given a skill name, description, and context about the user's work, generate detailed step-by-step instructions that the AI agent can follow to execute this skill.
|
|
274
|
-
|
|
275
|
-
The AI agent has these capabilities:
|
|
276
|
-
- Control a real Chrome browser (navigate, click, type, read pages, take screenshots)
|
|
277
|
-
- Read/write files on the local machine
|
|
278
|
-
- Execute shell commands (Bash)
|
|
279
|
-
- Store memories about the user
|
|
280
|
-
|
|
281
|
-
Rules:
|
|
282
|
-
- Write clear, actionable markdown instructions
|
|
283
|
-
- Use numbered steps with bold action names
|
|
284
|
-
- Include error handling (what to do if a page doesn't load, element not found, etc.)
|
|
285
|
-
- Use placeholders like {query}, {date}, {recipient} for variable inputs
|
|
286
|
-
- Include a $ARGUMENTS line at the top if the skill accepts parameters
|
|
287
|
-
- Be specific about which browser tools to use (browser_navigate, browser_click, etc.)
|
|
288
|
-
- Include validation steps (verify the action worked)
|
|
289
|
-
- Keep instructions thorough but concise (10-25 steps)
|
|
290
|
-
|
|
291
|
-
Respond with ONLY valid JSON — no markdown wrapping:
|
|
292
|
-
{"name": "skill-name", "description": "One-line description", "steps": "## Workflow\\n\\n$ARGUMENTS: describe expected arguments\\n\\n1. **Step one**\\n...", "emoji": "🔧", "keywords": ["keyword1", "keyword2"]}`;
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Generate a complete skill (with full instructions) from a description.
|
|
296
|
-
* Unlike extractSkillWithLLM which requires a completed task, this generates proactively.
|
|
297
|
-
*
|
|
298
|
-
* Cost: ~$0.003 per generation (Haiku, moderate output).
|
|
299
|
-
*/
|
|
300
|
-
export async function generateSkillFromDescription(
|
|
301
|
-
name: string,
|
|
302
|
-
description: string,
|
|
303
|
-
jobContext?: string
|
|
304
|
-
): Promise<ExtractedSkill & { keywords?: string[] } | null> {
|
|
305
|
-
const config = getConfig();
|
|
306
|
-
const apiKey = config.anthropicApiKey;
|
|
307
|
-
if (!apiKey) return null;
|
|
308
|
-
|
|
309
|
-
const contextBlock = jobContext
|
|
310
|
-
? `\n\n<user_job_context>\n${jobContext.slice(0, 1000)}\n</user_job_context>`
|
|
311
|
-
: "";
|
|
312
|
-
|
|
313
|
-
try {
|
|
314
|
-
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
315
|
-
method: "POST",
|
|
316
|
-
headers: {
|
|
317
|
-
"Content-Type": "application/json",
|
|
318
|
-
"x-api-key": apiKey,
|
|
319
|
-
"anthropic-version": "2023-06-01",
|
|
320
|
-
},
|
|
321
|
-
body: JSON.stringify({
|
|
322
|
-
model: "claude-haiku-4-5-20251001",
|
|
323
|
-
max_tokens: 2048,
|
|
324
|
-
system: GENERATE_PROMPT,
|
|
325
|
-
messages: [
|
|
326
|
-
{
|
|
327
|
-
role: "user",
|
|
328
|
-
content: `Generate a skill for:\nName: ${name}\nDescription: ${description}${contextBlock}`,
|
|
329
|
-
},
|
|
330
|
-
],
|
|
331
|
-
}),
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
if (!response.ok) {
|
|
335
|
-
const errText = await response.text().catch(() => "");
|
|
336
|
-
log.debug(`generateSkill API error: ${response.status} ${errText.slice(0, 300)}`);
|
|
337
|
-
return null;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
const data = await response.json();
|
|
341
|
-
const text =
|
|
342
|
-
data.content?.[0]?.type === "text" ? data.content[0].text : "";
|
|
343
|
-
if (!text) {
|
|
344
|
-
log.debug(`generateSkill: empty response from API`);
|
|
345
|
-
return null;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
const jsonStr = text
|
|
349
|
-
.replace(/```json\n?/g, "")
|
|
350
|
-
.replace(/```\n?/g, "")
|
|
351
|
-
.trim();
|
|
352
|
-
const skill = JSON.parse(jsonStr);
|
|
353
|
-
|
|
354
|
-
if (!skill || !skill.name || !skill.steps) {
|
|
355
|
-
log.debug(`generateSkill: invalid response structure: ${jsonStr.slice(0, 200)}`);
|
|
356
|
-
return null;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
skill.name = skill.name
|
|
360
|
-
.toLowerCase()
|
|
361
|
-
.replace(/[^a-z0-9-]/g, "-")
|
|
362
|
-
.replace(/-+/g, "-")
|
|
363
|
-
.replace(/^-|-$/g, "");
|
|
364
|
-
|
|
365
|
-
return skill;
|
|
366
|
-
} catch (err) {
|
|
367
|
-
log.debug(`Skill generation failed: ${err instanceof Error ? err.message : err}`);
|
|
368
|
-
return null;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
const IMPROVEMENT_PROMPT = `You are a skill improvement system. You have an EXISTING skill (workflow instructions for an AI agent) and a NEW task execution that used this skill. Analyze if the new execution discovered a BETTER approach than what the current skill describes.
|
|
373
|
-
|
|
374
|
-
Rules:
|
|
375
|
-
- Only suggest improvements if the new execution found a genuinely better workflow
|
|
376
|
-
- Merge the best parts of the old skill and new execution
|
|
377
|
-
- Keep instructions general/reusable (use placeholders like {product}, {query})
|
|
378
|
-
- Preserve the overall structure but update specific steps that improved
|
|
379
|
-
- If the existing skill is already optimal, return null
|
|
380
|
-
- The output must be the FULL updated skill content (not a diff)
|
|
381
|
-
|
|
382
|
-
Respond with ONLY valid JSON — no markdown wrapping:
|
|
383
|
-
|
|
384
|
-
If improvement found:
|
|
385
|
-
{"improved_steps": "## Workflow\\n\\n1. **Step one**\\n...", "change_summary": "Brief description of what changed"}
|
|
386
|
-
|
|
387
|
-
If no improvement needed:
|
|
388
|
-
null`;
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Analyze whether a skill should be improved based on a new task execution.
|
|
392
|
-
* Called when a task used an existing skill and completed successfully.
|
|
393
|
-
*/
|
|
394
|
-
export async function analyzeSkillImprovement(
|
|
395
|
-
existingSkillContent: string,
|
|
396
|
-
taskPrompt: string,
|
|
397
|
-
taskResult: string,
|
|
398
|
-
toolCalls: ToolCallRecord[]
|
|
399
|
-
): Promise<{ improved_steps: string; change_summary: string } | null> {
|
|
400
|
-
const config = getConfig();
|
|
401
|
-
const apiKey = config.anthropicApiKey;
|
|
402
|
-
|
|
403
|
-
if (!apiKey) return null;
|
|
404
|
-
|
|
405
|
-
const toolFlow = toolCalls
|
|
406
|
-
.map((tc, i) => {
|
|
407
|
-
const inputSummary = Object.entries(tc.input)
|
|
408
|
-
.map(([k, v]) => `${k}=${String(v).slice(0, 80)}`)
|
|
409
|
-
.join(", ");
|
|
410
|
-
return `${i + 1}. ${tc.name}(${inputSummary})`;
|
|
411
|
-
})
|
|
412
|
-
.slice(0, 15)
|
|
413
|
-
.join("\n");
|
|
414
|
-
|
|
415
|
-
const userMessage = `<existing_skill>
|
|
416
|
-
${existingSkillContent.slice(0, 2000)}
|
|
417
|
-
</existing_skill>
|
|
418
|
-
|
|
419
|
-
<new_task>
|
|
420
|
-
${taskPrompt.slice(0, 1000)}
|
|
421
|
-
</new_task>
|
|
422
|
-
|
|
423
|
-
<actual_tool_flow>
|
|
424
|
-
${toolFlow.slice(0, 2000)}
|
|
425
|
-
</actual_tool_flow>
|
|
426
|
-
|
|
427
|
-
<task_result>
|
|
428
|
-
${taskResult.slice(0, 1500)}
|
|
429
|
-
</task_result>`;
|
|
430
|
-
|
|
431
|
-
try {
|
|
432
|
-
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
433
|
-
method: "POST",
|
|
434
|
-
headers: {
|
|
435
|
-
"Content-Type": "application/json",
|
|
436
|
-
"x-api-key": apiKey,
|
|
437
|
-
"anthropic-version": "2023-06-01",
|
|
438
|
-
},
|
|
439
|
-
body: JSON.stringify({
|
|
440
|
-
model: "claude-haiku-4-5-20251001",
|
|
441
|
-
max_tokens: 1024,
|
|
442
|
-
system: IMPROVEMENT_PROMPT,
|
|
443
|
-
messages: [{ role: "user", content: userMessage }],
|
|
444
|
-
}),
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
if (!response.ok) return null;
|
|
448
|
-
|
|
449
|
-
const data = await response.json();
|
|
450
|
-
const text =
|
|
451
|
-
data.content?.[0]?.type === "text" ? data.content[0].text : "";
|
|
452
|
-
|
|
453
|
-
if (!text || text.trim() === "null") return null;
|
|
454
|
-
|
|
455
|
-
const jsonStr = text
|
|
456
|
-
.replace(/```json\n?/g, "")
|
|
457
|
-
.replace(/```\n?/g, "")
|
|
458
|
-
.trim();
|
|
459
|
-
const result = JSON.parse(jsonStr);
|
|
460
|
-
|
|
461
|
-
if (!result || !result.improved_steps) return null;
|
|
462
|
-
|
|
463
|
-
return result;
|
|
464
|
-
} catch {
|
|
465
|
-
return null;
|
|
466
|
-
}
|
|
467
|
-
}
|
package/src/agent/skills.ts
CHANGED
|
@@ -388,14 +388,18 @@ export class SkillManager {
|
|
|
388
388
|
return null;
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
-
const row = Array.isArray(data) ? data[0] : data;
|
|
391
|
+
const row = (Array.isArray(data) ? data[0] : data) as Record<string, unknown> | null;
|
|
392
392
|
if (!row) {
|
|
393
393
|
log.debug(`Skill create returned no data for "${name}"`);
|
|
394
394
|
return null;
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
-
|
|
398
|
-
|
|
397
|
+
// RPC returns out_id/out_name to avoid column ambiguity
|
|
398
|
+
const id = (row.out_id || row.id) as string;
|
|
399
|
+
const skillName = (row.out_name || row.name) as string;
|
|
400
|
+
|
|
401
|
+
log.info(`Skill "${skillName}" created in skills table (pending approval)`);
|
|
402
|
+
return { id, name: skillName };
|
|
399
403
|
} catch (err) {
|
|
400
404
|
log.debug(`Skill create error: ${err}`);
|
|
401
405
|
return null;
|
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import { getConfig } from "../utils/config.js";
|
|
2
|
-
import { log } from "../utils/logger.js";
|
|
3
|
-
import type { MemoryCategory } from "./memory.js";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Extracted memory from LLM analysis of a conversation.
|
|
7
|
-
*/
|
|
8
|
-
export interface ExtractedMemory {
|
|
9
|
-
content: string;
|
|
10
|
-
category: MemoryCategory;
|
|
11
|
-
importance: number;
|
|
12
|
-
tags: string[];
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const EXTRACTION_PROMPT = `You are a memory extraction system. Analyze the following conversation between a user and an AI assistant. Extract any facts worth remembering about the user for future conversations.
|
|
16
|
-
|
|
17
|
-
Rules:
|
|
18
|
-
- Only extract FACTUAL information about the user, not about the task itself
|
|
19
|
-
- Be concise: each memory should be a single clear statement
|
|
20
|
-
- Don't extract trivial/obvious things (e.g. "user asked a question")
|
|
21
|
-
- Categories: "preference" (likes/dislikes/habits), "instruction" (standing orders like "always do X"), "context" (work/life info), "fact" (specific facts), "general" (other)
|
|
22
|
-
- Importance: 3-4 for minor preferences, 5-6 for useful context, 7-8 for strong preferences/instructions, 9-10 for critical standing instructions
|
|
23
|
-
- If nothing worth remembering, return an empty array
|
|
24
|
-
- Max 3 memories per conversation
|
|
25
|
-
- Support both English and Chinese content
|
|
26
|
-
|
|
27
|
-
Respond with ONLY valid JSON — no markdown, no explanation:
|
|
28
|
-
[{"content": "...", "category": "...", "importance": N, "tags": ["..."]}]
|
|
29
|
-
|
|
30
|
-
If nothing to remember: []`;
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Use Haiku to analyze a task conversation and extract memories.
|
|
34
|
-
* This runs as a fire-and-forget background task after each completed task.
|
|
35
|
-
* Cost: ~$0.001 per extraction (Haiku is very cheap).
|
|
36
|
-
*/
|
|
37
|
-
export async function extractMemoriesWithLLM(
|
|
38
|
-
taskPrompt: string,
|
|
39
|
-
taskResult: string
|
|
40
|
-
): Promise<ExtractedMemory[]> {
|
|
41
|
-
const config = getConfig();
|
|
42
|
-
const apiKey = config.anthropicApiKey;
|
|
43
|
-
|
|
44
|
-
if (!apiKey) {
|
|
45
|
-
log.debug("No API key, skipping LLM memory extraction");
|
|
46
|
-
return [];
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Truncate to keep costs low — Haiku context is cheap but no need to waste
|
|
50
|
-
const truncatedPrompt = taskPrompt.slice(0, 2000);
|
|
51
|
-
const truncatedResult = taskResult.slice(0, 3000);
|
|
52
|
-
|
|
53
|
-
const userMessage = `<user_request>
|
|
54
|
-
${truncatedPrompt}
|
|
55
|
-
</user_request>
|
|
56
|
-
|
|
57
|
-
<assistant_response>
|
|
58
|
-
${truncatedResult}
|
|
59
|
-
</assistant_response>`;
|
|
60
|
-
|
|
61
|
-
try {
|
|
62
|
-
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
63
|
-
method: "POST",
|
|
64
|
-
headers: {
|
|
65
|
-
"Content-Type": "application/json",
|
|
66
|
-
"x-api-key": apiKey,
|
|
67
|
-
"anthropic-version": "2023-06-01",
|
|
68
|
-
},
|
|
69
|
-
body: JSON.stringify({
|
|
70
|
-
model: "claude-haiku-4-5-20251001",
|
|
71
|
-
max_tokens: 512,
|
|
72
|
-
system: EXTRACTION_PROMPT,
|
|
73
|
-
messages: [{ role: "user", content: userMessage }],
|
|
74
|
-
}),
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
if (!response.ok) {
|
|
78
|
-
const errText = await response.text();
|
|
79
|
-
log.debug(`Memory extraction API error: ${response.status} ${errText.slice(0, 200)}`);
|
|
80
|
-
return [];
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const data = await response.json();
|
|
84
|
-
|
|
85
|
-
// Extract text from response
|
|
86
|
-
const text =
|
|
87
|
-
data.content?.[0]?.type === "text" ? data.content[0].text : "";
|
|
88
|
-
|
|
89
|
-
if (!text || text.trim() === "[]") {
|
|
90
|
-
log.debug("LLM memory extraction: nothing to remember");
|
|
91
|
-
return [];
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Parse JSON — handle potential markdown wrapping
|
|
95
|
-
const jsonStr = text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim();
|
|
96
|
-
const memories: ExtractedMemory[] = JSON.parse(jsonStr);
|
|
97
|
-
|
|
98
|
-
// Validate
|
|
99
|
-
const validCategories: MemoryCategory[] = [
|
|
100
|
-
"general",
|
|
101
|
-
"preference",
|
|
102
|
-
"instruction",
|
|
103
|
-
"context",
|
|
104
|
-
"skill_learned",
|
|
105
|
-
"fact",
|
|
106
|
-
];
|
|
107
|
-
|
|
108
|
-
return memories
|
|
109
|
-
.filter(
|
|
110
|
-
(m) =>
|
|
111
|
-
m.content &&
|
|
112
|
-
m.content.length > 5 &&
|
|
113
|
-
validCategories.includes(m.category)
|
|
114
|
-
)
|
|
115
|
-
.map((m) => ({
|
|
116
|
-
content: m.content.slice(0, 500),
|
|
117
|
-
category: m.category,
|
|
118
|
-
importance: Math.max(1, Math.min(10, m.importance || 5)),
|
|
119
|
-
tags: Array.isArray(m.tags) ? m.tags.slice(0, 5) : [],
|
|
120
|
-
}))
|
|
121
|
-
.slice(0, 3); // Max 3 memories per conversation
|
|
122
|
-
} catch (err) {
|
|
123
|
-
log.debug(
|
|
124
|
-
`LLM memory extraction failed: ${err instanceof Error ? err.message : err}`
|
|
125
|
-
);
|
|
126
|
-
return [];
|
|
127
|
-
}
|
|
128
|
-
}
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
isValidUrl,
|
|
4
|
-
isValidApiKey,
|
|
5
|
-
isValidPort,
|
|
6
|
-
isValidMaxTurns,
|
|
7
|
-
validateConfig,
|
|
8
|
-
sanitizeForIlike,
|
|
9
|
-
sanitizeSelector,
|
|
10
|
-
} from "./validation.js";
|
|
11
|
-
|
|
12
|
-
describe("isValidUrl", () => {
|
|
13
|
-
it("accepts valid HTTPS URLs", () => {
|
|
14
|
-
expect(isValidUrl("https://example.supabase.co")).toBe(true);
|
|
15
|
-
expect(isValidUrl("https://localhost:3000")).toBe(true);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("accepts HTTP URLs", () => {
|
|
19
|
-
expect(isValidUrl("http://localhost:54321")).toBe(true);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("rejects invalid URLs", () => {
|
|
23
|
-
expect(isValidUrl("not-a-url")).toBe(false);
|
|
24
|
-
expect(isValidUrl("ftp://files.example.com")).toBe(false);
|
|
25
|
-
expect(isValidUrl("")).toBe(false);
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe("isValidApiKey", () => {
|
|
30
|
-
it("accepts valid Anthropic keys", () => {
|
|
31
|
-
expect(isValidApiKey("sk-ant-api03-aaaabbbbccccddddeeeeffffgggghhhhiiiijjjj")).toBe(true);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("rejects invalid keys", () => {
|
|
35
|
-
expect(isValidApiKey("sk-1234567890")).toBe(false);
|
|
36
|
-
expect(isValidApiKey("")).toBe(false);
|
|
37
|
-
expect(isValidApiKey("sk-ant-short")).toBe(false);
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe("isValidPort", () => {
|
|
42
|
-
it("accepts valid ports", () => {
|
|
43
|
-
expect(isValidPort(9222)).toBe(true);
|
|
44
|
-
expect(isValidPort(1)).toBe(true);
|
|
45
|
-
expect(isValidPort(65535)).toBe(true);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("rejects invalid ports", () => {
|
|
49
|
-
expect(isValidPort(0)).toBe(false);
|
|
50
|
-
expect(isValidPort(65536)).toBe(false);
|
|
51
|
-
expect(isValidPort(-1)).toBe(false);
|
|
52
|
-
expect(isValidPort(3.5)).toBe(false);
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
describe("isValidMaxTurns", () => {
|
|
57
|
-
it("accepts valid turn counts", () => {
|
|
58
|
-
expect(isValidMaxTurns(50)).toBe(true);
|
|
59
|
-
expect(isValidMaxTurns(1)).toBe(true);
|
|
60
|
-
expect(isValidMaxTurns(500)).toBe(true);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("rejects invalid turn counts", () => {
|
|
64
|
-
expect(isValidMaxTurns(0)).toBe(false);
|
|
65
|
-
expect(isValidMaxTurns(501)).toBe(false);
|
|
66
|
-
expect(isValidMaxTurns(-10)).toBe(false);
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
describe("validateConfig", () => {
|
|
71
|
-
const validConfig = {
|
|
72
|
-
supabaseUrl: "https://example.supabase.co",
|
|
73
|
-
supabaseAnonKey: "some-anon-key",
|
|
74
|
-
anthropicApiKey: "sk-ant-api03-aaaabbbbccccddddeeeeffffgggghhhhiiiijjjj",
|
|
75
|
-
workspacePath: "/home/user/workspace",
|
|
76
|
-
maxTurns: 50,
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
it("validates a correct config", () => {
|
|
80
|
-
const result = validateConfig(validConfig);
|
|
81
|
-
expect(result.valid).toBe(true);
|
|
82
|
-
expect(result.errors).toHaveLength(0);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("catches missing supabaseUrl", () => {
|
|
86
|
-
const result = validateConfig({ ...validConfig, supabaseUrl: "" });
|
|
87
|
-
expect(result.valid).toBe(false);
|
|
88
|
-
expect(result.errors.some((e) => e.includes("supabaseUrl"))).toBe(true);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("catches invalid URL format", () => {
|
|
92
|
-
const result = validateConfig({ ...validConfig, supabaseUrl: "not-url" });
|
|
93
|
-
expect(result.valid).toBe(false);
|
|
94
|
-
expect(result.errors.some((e) => e.includes("not a valid URL"))).toBe(true);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("catches missing API key", () => {
|
|
98
|
-
const result = validateConfig({ ...validConfig, anthropicApiKey: "" });
|
|
99
|
-
expect(result.valid).toBe(false);
|
|
100
|
-
expect(result.errors.some((e) => e.includes("anthropicApiKey"))).toBe(true);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("catches invalid API key format", () => {
|
|
104
|
-
const result = validateConfig({ ...validConfig, anthropicApiKey: "bad-key" });
|
|
105
|
-
expect(result.valid).toBe(false);
|
|
106
|
-
expect(result.errors.some((e) => e.includes("sk-ant-"))).toBe(true);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("catches invalid maxTurns", () => {
|
|
110
|
-
const result = validateConfig({ ...validConfig, maxTurns: 0 });
|
|
111
|
-
expect(result.valid).toBe(false);
|
|
112
|
-
expect(result.errors.some((e) => e.includes("maxTurns"))).toBe(true);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("collects multiple errors", () => {
|
|
116
|
-
const result = validateConfig({
|
|
117
|
-
supabaseUrl: "",
|
|
118
|
-
supabaseAnonKey: "",
|
|
119
|
-
anthropicApiKey: "",
|
|
120
|
-
workspacePath: "",
|
|
121
|
-
maxTurns: -1,
|
|
122
|
-
});
|
|
123
|
-
expect(result.valid).toBe(false);
|
|
124
|
-
expect(result.errors.length).toBeGreaterThanOrEqual(4);
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
describe("sanitizeForIlike", () => {
|
|
129
|
-
it("escapes % and _ wildcards", () => {
|
|
130
|
-
expect(sanitizeForIlike("test%value_here")).toBe("test\\%value\\_here");
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("leaves normal strings unchanged", () => {
|
|
134
|
-
expect(sanitizeForIlike("hello world")).toBe("hello world");
|
|
135
|
-
});
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
describe("sanitizeSelector", () => {
|
|
139
|
-
it("removes script tags", () => {
|
|
140
|
-
expect(sanitizeSelector("<script>alert(1)</script>#btn")).toBe(
|
|
141
|
-
"alert(1)#btn"
|
|
142
|
-
);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("removes event handlers", () => {
|
|
146
|
-
expect(sanitizeSelector("div[onclick=evil]")).toBe("div[evil]");
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("passes through valid selectors", () => {
|
|
150
|
-
expect(sanitizeSelector("#submit-btn")).toBe("#submit-btn");
|
|
151
|
-
expect(sanitizeSelector("a.nav-link")).toBe("a.nav-link");
|
|
152
|
-
});
|
|
153
|
-
});
|
package/src/utils/validation.ts
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Input validation helpers for CLI configuration and tool inputs.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
export interface ValidationResult {
|
|
6
|
-
valid: boolean;
|
|
7
|
-
errors: string[];
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Validate that a URL is well-formed (https:// or http://).
|
|
12
|
-
*/
|
|
13
|
-
export function isValidUrl(url: string): boolean {
|
|
14
|
-
try {
|
|
15
|
-
const parsed = new URL(url);
|
|
16
|
-
return parsed.protocol === "https:" || parsed.protocol === "http:";
|
|
17
|
-
} catch {
|
|
18
|
-
return false;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Validate an Anthropic API key format.
|
|
24
|
-
* Real keys start with "sk-ant-" and are at least 40 chars.
|
|
25
|
-
*/
|
|
26
|
-
export function isValidApiKey(key: string): boolean {
|
|
27
|
-
return key.startsWith("sk-ant-") && key.length >= 40;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Validate a port number is in valid range.
|
|
32
|
-
*/
|
|
33
|
-
export function isValidPort(port: number): boolean {
|
|
34
|
-
return Number.isInteger(port) && port >= 1 && port <= 65535;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Validate maxTurns is a reasonable number.
|
|
39
|
-
*/
|
|
40
|
-
export function isValidMaxTurns(turns: number): boolean {
|
|
41
|
-
return Number.isInteger(turns) && turns >= 1 && turns <= 500;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Validate the full CLI configuration.
|
|
46
|
-
* Returns errors for any required/invalid fields.
|
|
47
|
-
*/
|
|
48
|
-
export function validateConfig(config: {
|
|
49
|
-
supabaseUrl: string;
|
|
50
|
-
supabaseAnonKey: string;
|
|
51
|
-
anthropicApiKey: string;
|
|
52
|
-
workspacePath: string;
|
|
53
|
-
maxTurns: number;
|
|
54
|
-
}): ValidationResult {
|
|
55
|
-
const errors: string[] = [];
|
|
56
|
-
|
|
57
|
-
if (!config.supabaseUrl) {
|
|
58
|
-
errors.push("supabaseUrl is required. Run: assistme config set supabaseUrl <url>");
|
|
59
|
-
} else if (!isValidUrl(config.supabaseUrl)) {
|
|
60
|
-
errors.push(`supabaseUrl is not a valid URL: "${config.supabaseUrl}"`);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (!config.supabaseAnonKey) {
|
|
64
|
-
errors.push("supabaseAnonKey is required. Run: assistme config set supabaseAnonKey <key>");
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (!config.anthropicApiKey) {
|
|
68
|
-
errors.push("anthropicApiKey is required. Set ANTHROPIC_API_KEY env var or run: assistme config set anthropicApiKey <key>");
|
|
69
|
-
} else if (!isValidApiKey(config.anthropicApiKey)) {
|
|
70
|
-
errors.push("anthropicApiKey does not look like a valid Anthropic key (expected sk-ant-...)");
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (!config.workspacePath) {
|
|
74
|
-
errors.push("workspacePath is required");
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (!isValidMaxTurns(config.maxTurns)) {
|
|
78
|
-
errors.push(`maxTurns must be between 1 and 500, got: ${config.maxTurns}`);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return { valid: errors.length === 0, errors };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Sanitize user-provided strings for safe use in ILIKE queries.
|
|
86
|
-
* Escapes SQL wildcards % and _.
|
|
87
|
-
*/
|
|
88
|
-
export function sanitizeForIlike(input: string): string {
|
|
89
|
-
return input.replace(/[%_]/g, "\\$&");
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Validate and sanitize a CSS selector to prevent obvious injection.
|
|
94
|
-
*/
|
|
95
|
-
export function sanitizeSelector(selector: string): string {
|
|
96
|
-
// Remove any embedded script tags (opening and closing) or event handlers
|
|
97
|
-
return selector
|
|
98
|
-
.replace(/<\/?script[^>]*>/gi, "")
|
|
99
|
-
.replace(/on\w+\s*=/gi, "")
|
|
100
|
-
.trim();
|
|
101
|
-
}
|