skill manager 添加脚本管理功能,支持写入和删除可复用脚本
This commit is contained in:
@@ -27,5 +27,5 @@ temperature: 0.2
|
||||
15. 在以下任一情况出现时,主动进行一次轻量复盘:连续多轮对话后、完成复杂多工具任务后、用户明确纠正你后、发现了稳定可复用 workflow 后。复盘的目标是判断是否需要沉淀 memory 或 skill,而不是向用户重复总结。
|
||||
16. 长期知识严格分流:`memory_manager` 仅保存用户长期偏好与稳定 workspace 事实;`skill_manager` 仅保存可复用方法;一次性案例、会话过程与临时结论应优先保留在 session history,需要时使用 `session_search` 检索,不要误写入 memory 或 skill。
|
||||
17. 写入 `memory_manager` 时,将内容写成简短陈述事实,不要写成命令句、提醒句或流程步骤。
|
||||
18. 更新 skill 时,优先补充现有 skill 的 `Learned Patterns` 或 `references/`,只有现有位置明显不合适时才新建新的 skill 目录。
|
||||
18. 更新 skill 时,优先补充现有 skill 的 `Learned Patterns`、`references/` 或 `scripts/`;可复用脚本仅允许写到当前 skill 自己的 `scripts/*.py`,不要放到 `data/` 或其他 skill 目录。
|
||||
19. 当用户问题依赖过去会话中的案例、约束、决策或相似问题时,优先调用 `session_search`,避免让用户重复描述,也避免把历史案例误当成长期 memory。
|
||||
|
||||
@@ -26,6 +26,7 @@ version: 1.2.0
|
||||
- 如果 workflow 已覆盖主要步骤,则不要先从大量 API skills 开始拼装流程;仅在 workflow 缺失、步骤不全或需要额外原子能力时,才继续下钻。
|
||||
- 优先更新已有 skill,而不是为一次性问题新增新的 skill 目录。
|
||||
- learned pattern 应写成可复用的方法或坑点,不应写成某次会话的流水账。
|
||||
- 某个 workflow 反复验证过的私有辅助脚本,应放在该 skill 目录下的 `scripts/*.py`,并随 skill 一起维护;不要写入 `data/`。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -173,3 +173,19 @@ opencode agent 调用工具 `skill_manager`:
|
||||
"pattern": "当瓶颈分析依赖大体量属性数据和模拟结果时,先用 dynamic_http_call 获取 preview,再用 fetch_result_ref 回读完整数据后再做合并与排序。"
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 9:给单个 workflow skill 写入可复用脚本
|
||||
|
||||
当某个 workflow 的本地 Python 处理逻辑已经稳定、未来同类任务会重复使用时,可写入该 skill 自己的 `scripts/*.py`:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "write_script",
|
||||
"reason": "本轮已验证瓶颈分析中的合并与排序脚本,后续同类 workflow 可直接复用。",
|
||||
"skill_path": "workflow/bottleneck-analysis",
|
||||
"file_path": "scripts/merge_and_rank.py",
|
||||
"content": "import json\n\n\ndef rank_links(rows):\n return sorted(rows, key=lambda row: row['composite_score'], reverse=True)\n"
|
||||
}
|
||||
```
|
||||
|
||||
脚本应只归属当前 `skill_path`,不要写到 `data/` 或其他 skill 目录。
|
||||
|
||||
@@ -71,12 +71,14 @@ SSE 事件:
|
||||
|
||||
- 所有学习类工具都必须带 `reason`
|
||||
- `memory_manager` 支持:`add / list / replace / remove`
|
||||
- `skill_manager` 支持:`list / append_pattern / remove_pattern / write_reference / remove_reference`
|
||||
- `skill_manager` 支持:`list / append_pattern / remove_pattern / write_reference / remove_reference / write_script / remove_script`
|
||||
- `session_search` 只搜索当前用户 + 当前项目作用域,不接受跨项目检索
|
||||
- `skill_manager` 的结构化写入优先落到:
|
||||
1. `## Learned Patterns`
|
||||
2. `references/*.md`
|
||||
3. `scripts/*.py`
|
||||
不应直接重写 skill frontmatter 或任意正文段落
|
||||
- `scripts/*.py` 仅表示当前 `skill_path` 私有的可复用脚本资产;不要把运行时临时脚本写进 `data/`
|
||||
|
||||
## 4) 用户上下文注入(后端执行阶段)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const skillStore = new SkillStore();
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、append_pattern、remove_pattern、write_reference、remove_reference。",
|
||||
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。",
|
||||
args: {
|
||||
action: tool.schema
|
||||
.enum([
|
||||
@@ -18,6 +18,8 @@ export default tool({
|
||||
"remove_pattern",
|
||||
"write_reference",
|
||||
"remove_reference",
|
||||
"write_script",
|
||||
"remove_script",
|
||||
])
|
||||
.describe("Skill maintenance operation."),
|
||||
reason: tool.schema
|
||||
@@ -41,11 +43,11 @@ export default tool({
|
||||
file_path: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Reference file path under references/, such as references/bottleneck-notes.md."),
|
||||
.describe("Asset file path. For references use references/*.md; for scripts use scripts/*.py."),
|
||||
content: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Reference markdown body used by write_reference."),
|
||||
.describe("Asset content used by write_reference or write_script."),
|
||||
},
|
||||
async execute(args, context) {
|
||||
await initializePromise;
|
||||
@@ -92,7 +94,15 @@ export default tool({
|
||||
args.file_path ?? "",
|
||||
args.content ?? "",
|
||||
)
|
||||
: await skillStore.removeReference(args.skill_path, args.file_path ?? "");
|
||||
: args.action === "remove_reference"
|
||||
? await skillStore.removeReference(args.skill_path, args.file_path ?? "")
|
||||
: args.action === "write_script"
|
||||
? await skillStore.writeScript(
|
||||
args.skill_path,
|
||||
args.file_path ?? "",
|
||||
args.content ?? "",
|
||||
)
|
||||
: await skillStore.removeScript(args.skill_path, args.file_path ?? "");
|
||||
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
toStableId,
|
||||
} from "../utils/fileStore.js";
|
||||
import {
|
||||
sanitizePersistentScript,
|
||||
sanitizePersistentDocument,
|
||||
sanitizePersistentLine,
|
||||
} from "../utils/persistencePolicy.js";
|
||||
@@ -36,6 +37,7 @@ export class SkillStore {
|
||||
const current = (await readTextFile(target)) ?? defaultLearnedSkill(normalizedSkillPath);
|
||||
return {
|
||||
references: await this.listReferenceFiles(normalizedSkillPath),
|
||||
scripts: await this.listScriptFiles(normalizedSkillPath),
|
||||
skillPath: normalizedSkillPath,
|
||||
target,
|
||||
patterns: extractLearnedPatterns(current),
|
||||
@@ -146,12 +148,62 @@ export class SkillStore {
|
||||
});
|
||||
}
|
||||
|
||||
async writeScript(skillPath: string, filePath: string, content: string) {
|
||||
const normalizedSkillPath = normalizeSkillPath(skillPath);
|
||||
const normalizedScriptPath = normalizeScriptPath(filePath);
|
||||
if (!normalizedSkillPath) {
|
||||
return { changed: false, detail: "invalid skill_path", target: "" };
|
||||
}
|
||||
if (!normalizedScriptPath) {
|
||||
return { changed: false, detail: "invalid script file_path", target: "" };
|
||||
}
|
||||
const sanitizedContent = sanitizePersistentScript(content, 20000);
|
||||
if (!sanitizedContent) {
|
||||
return { changed: false, detail: "script content rejected by persistence policy", target: "" };
|
||||
}
|
||||
return this.serializeWrite(async () => {
|
||||
const target = join(SKILLS_ROOT_DIR, normalizedSkillPath, normalizedScriptPath);
|
||||
await ensureDirectory(dirname(target));
|
||||
await atomicWriteFileWithHistory(target, sanitizedContent, {
|
||||
historyDir: SKILLS_HISTORY_DIR,
|
||||
rootDir: SKILLS_ROOT_DIR,
|
||||
});
|
||||
return { changed: true, detail: "script written", target };
|
||||
});
|
||||
}
|
||||
|
||||
async removeScript(skillPath: string, filePath: string) {
|
||||
const normalizedSkillPath = normalizeSkillPath(skillPath);
|
||||
const normalizedScriptPath = normalizeScriptPath(filePath);
|
||||
if (!normalizedSkillPath) {
|
||||
return { changed: false, detail: "invalid skill_path", target: "" };
|
||||
}
|
||||
if (!normalizedScriptPath) {
|
||||
return { changed: false, detail: "invalid script file_path", target: "" };
|
||||
}
|
||||
return this.serializeWrite(async () => {
|
||||
const target = join(SKILLS_ROOT_DIR, normalizedSkillPath, normalizedScriptPath);
|
||||
const previous = await readTextFile(target);
|
||||
if (previous === null) {
|
||||
return { changed: false, detail: "script not found", target };
|
||||
}
|
||||
await removeFileIfExists(target);
|
||||
return { changed: true, detail: "script removed", target };
|
||||
});
|
||||
}
|
||||
|
||||
private async listReferenceFiles(skillPath: string) {
|
||||
const referenceDir = join(SKILLS_ROOT_DIR, skillPath, "references");
|
||||
const files = await listFiles(referenceDir);
|
||||
return files.map((file) => file.slice(referenceDir.length + 1));
|
||||
}
|
||||
|
||||
private async listScriptFiles(skillPath: string) {
|
||||
const scriptDir = join(SKILLS_ROOT_DIR, skillPath, "scripts");
|
||||
const files = await listFiles(scriptDir);
|
||||
return files.map((file) => file.slice(scriptDir.length + 1));
|
||||
}
|
||||
|
||||
private skillFilePath(skillPath: string) {
|
||||
return join(SKILLS_ROOT_DIR, skillPath, "SKILL.md");
|
||||
}
|
||||
@@ -201,6 +253,27 @@ const normalizeReferencePath = (rawFilePath: string) => {
|
||||
return [...segments, `${normalizedStem}.md`].join("/");
|
||||
};
|
||||
|
||||
const normalizeScriptPath = (rawFilePath: string) => {
|
||||
const normalized = posix.normalize(rawFilePath.trim().replace(/^\/+|\/+$/g, ""));
|
||||
if (!normalized || normalized.startsWith("..")) {
|
||||
return null;
|
||||
}
|
||||
if (!normalized.startsWith("scripts/")) {
|
||||
return null;
|
||||
}
|
||||
if (!normalized.endsWith(".py")) {
|
||||
return null;
|
||||
}
|
||||
const segments = normalized.split("/");
|
||||
const last = segments.pop();
|
||||
if (!last) {
|
||||
return null;
|
||||
}
|
||||
const stem = last.replace(/\.py$/i, "");
|
||||
const normalizedStem = slugify(stem);
|
||||
return [...segments, `${normalizedStem}.py`].join("/");
|
||||
};
|
||||
|
||||
export const extractLearnedPatterns = (content: string): SkillPatternRecord[] => {
|
||||
const section = extractLearnedPatternsSection(content);
|
||||
if (!section) {
|
||||
|
||||
@@ -14,12 +14,15 @@ const FORBIDDEN_PERSISTENCE_PATTERNS = [
|
||||
/eyJ[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9._-]{8,}\.[a-zA-Z0-9._-]{8,}/,
|
||||
];
|
||||
|
||||
export const containsForbiddenPersistentContent = (content: string) =>
|
||||
FORBIDDEN_PERSISTENCE_PATTERNS.some((pattern) => pattern.test(content));
|
||||
|
||||
export const sanitizePersistentLine = (content: string, maxLength: number) => {
|
||||
const normalized = content.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
if (FORBIDDEN_PERSISTENCE_PATTERNS.some((pattern) => pattern.test(normalized))) {
|
||||
if (containsForbiddenPersistentContent(normalized)) {
|
||||
return "";
|
||||
}
|
||||
if (normalized.length > maxLength) {
|
||||
@@ -39,7 +42,7 @@ export const sanitizePersistentDocument = (content: string, maxLength: number) =
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
if (FORBIDDEN_PERSISTENCE_PATTERNS.some((pattern) => pattern.test(normalized))) {
|
||||
if (containsForbiddenPersistentContent(normalized)) {
|
||||
return "";
|
||||
}
|
||||
if (normalized.length > maxLength) {
|
||||
@@ -47,3 +50,17 @@ export const sanitizePersistentDocument = (content: string, maxLength: number) =
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const sanitizePersistentScript = (content: string, maxLength: number) => {
|
||||
const normalized = content.replace(/\r\n/g, "\n").replace(/\t/g, " ").trim();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
if (containsForbiddenPersistentContent(normalized)) {
|
||||
return "";
|
||||
}
|
||||
if (normalized.length > maxLength) {
|
||||
return "";
|
||||
}
|
||||
return `${normalized}\n`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user