LLM-driven 设计,添加学习审计和会话历史存储至目录的功能
This commit is contained in:
@@ -11,8 +11,11 @@ const initializePromise = Promise.all([
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
"将长期有效的用户偏好或项目事实写入持久 memory。禁止写入 token、password、secret、system prompt 或一次性上下文。scope 仅允许 'user' 或 'workspace'。",
|
||||
"管理长期有效的用户偏好或项目事实。支持 add/list/replace/remove。禁止写入 token、password、secret、system prompt 或一次性上下文。scope 仅允许 'user' 或 'workspace'。",
|
||||
args: {
|
||||
action: tool.schema
|
||||
.enum(["add", "list", "replace", "remove"])
|
||||
.describe("Memory operation to perform."),
|
||||
reason: tool.schema
|
||||
.string()
|
||||
.describe("Why this memory should be persisted for future requests."),
|
||||
@@ -23,9 +26,14 @@ export default tool({
|
||||
),
|
||||
content: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"The durable fact or preference to remember, written as one concise sentence.",
|
||||
),
|
||||
target_id: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Stable memory entry id used by replace/remove."),
|
||||
},
|
||||
async execute(args, context) {
|
||||
await initializePromise;
|
||||
@@ -47,30 +55,75 @@ export default tool({
|
||||
detail: `unsupported scope: ${args.scope}; use exact keyword 'user' or 'workspace'`,
|
||||
});
|
||||
}
|
||||
if (sessionContext.allowLearningWrite === false && args.action !== "list") {
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "memory",
|
||||
decision: "rejected",
|
||||
detail: "memory writes are disabled for this session",
|
||||
});
|
||||
}
|
||||
|
||||
const scopeKey =
|
||||
scope === "user" ? sessionContext.actorKey : sessionContext.projectKey;
|
||||
const result = await memoryStore.upsert(scope, scopeKey, {
|
||||
content: args.content,
|
||||
if (args.action === "list") {
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "memory",
|
||||
decision: "accepted",
|
||||
detail: "memory listed",
|
||||
items: await memoryStore.list(scope, scopeKey),
|
||||
target: scope,
|
||||
});
|
||||
}
|
||||
|
||||
if (args.action === "add") {
|
||||
const result = await memoryStore.upsert(scope, scopeKey, {
|
||||
content: args.content ?? "",
|
||||
sessionId: context.sessionID,
|
||||
source: "tool",
|
||||
traceId: sessionContext.traceId,
|
||||
});
|
||||
|
||||
if (!result.entry) {
|
||||
});
|
||||
if (!result.entry) {
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "memory",
|
||||
decision: "rejected",
|
||||
detail: "content rejected by persistence policy",
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
}
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "memory",
|
||||
decision: result.changed ? "accepted" : "deduped",
|
||||
detail: result.changed ? "memory stored" : "memory already existed",
|
||||
entry: result.entry,
|
||||
target: scope,
|
||||
});
|
||||
}
|
||||
|
||||
if (args.action === "replace") {
|
||||
const result = await memoryStore.replace(scope, scopeKey, args.target_id ?? "", {
|
||||
content: args.content ?? "",
|
||||
sessionId: context.sessionID,
|
||||
source: "tool",
|
||||
traceId: sessionContext.traceId,
|
||||
});
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "memory",
|
||||
decision: result.changed ? "accepted" : "rejected",
|
||||
detail: result.detail,
|
||||
target: scope,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await memoryStore.remove(scope, scopeKey, args.target_id ?? "");
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "memory",
|
||||
decision: result.changed ? "accepted" : "rejected",
|
||||
detail: result.detail,
|
||||
target: scope,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
const internalBaseUrl =
|
||||
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
|
||||
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
"搜索当前用户和项目范围内的历史会话 transcript。适合回忆过去讨论过的案例、约束和结论,避免把一次性案例写入 memory。",
|
||||
args: {
|
||||
reason: tool.schema
|
||||
.string()
|
||||
.describe("Why prior session history is needed for the current request."),
|
||||
query: tool.schema
|
||||
.string()
|
||||
.describe("What to search for in prior session history."),
|
||||
max_results: tool.schema
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe("Optional maximum number of hits to return."),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const response = await fetch(`${internalBaseUrl}/internal/tools/session-search`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-agent-internal-token": internalToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
max_results: args.max_results,
|
||||
query: args.query,
|
||||
sessionId: context.sessionID,
|
||||
}),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text);
|
||||
}
|
||||
return text;
|
||||
},
|
||||
});
|
||||
@@ -1,37 +1,51 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import { join, posix } from "node:path";
|
||||
|
||||
import { config } from "../../src/config.js";
|
||||
import { SkillStore } from "../../src/skills/store.js";
|
||||
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
|
||||
import {
|
||||
atomicWriteFileWithHistory,
|
||||
ensureDirectory,
|
||||
readTextFile,
|
||||
} from "../../src/utils/fileStore.js";
|
||||
import { sanitizePersistentLine } from "../../src/utils/persistencePolicy.js";
|
||||
|
||||
const toolContextStore = new ToolSessionContextStore();
|
||||
const initializePromise = toolContextStore.initialize();
|
||||
const SKILLS_ROOT_DIR = ".opencode/skills";
|
||||
// learned skill 与正式技能树同路径组织,但历史版本单独落到 data/history/skills 下。
|
||||
const SKILLS_HISTORY_DIR = join(config.PERSISTENCE_HISTORY_DIR, "skills");
|
||||
const LEARNED_PATTERNS_MARKER = "## Learned Patterns";
|
||||
let writeQueue: Promise<void> = Promise.resolve();
|
||||
const skillStore = new SkillStore();
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
"将已验证、可复用、非敏感的 workflow 或方法模式写入指定的 .opencode/skills 目录,由 opencode 自动识别和加载。",
|
||||
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、append_pattern、remove_pattern、write_reference、remove_reference。",
|
||||
args: {
|
||||
action: tool.schema
|
||||
.enum([
|
||||
"list",
|
||||
"append_pattern",
|
||||
"remove_pattern",
|
||||
"write_reference",
|
||||
"remove_reference",
|
||||
])
|
||||
.describe("Skill maintenance operation."),
|
||||
reason: tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
"The reusable workflow or method pattern to persist for future reuse, written as one concise sentence.",
|
||||
"Why this skill maintenance action is justified for future reuse.",
|
||||
),
|
||||
skill_path: tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
"Target skill directory path relative to .opencode/skills, for example analytics/simulation-analysis/leakage or platform/governance-observability/meta.",
|
||||
),
|
||||
pattern: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Pattern text used by append_pattern."),
|
||||
target_id: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Stable learned pattern id used by remove_pattern."),
|
||||
file_path: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Reference file path under references/, such as references/bottleneck-notes.md."),
|
||||
content: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Reference markdown body used by write_reference."),
|
||||
},
|
||||
async execute(args, context) {
|
||||
await initializePromise;
|
||||
@@ -39,122 +53,53 @@ export default tool({
|
||||
if (!sessionContext) {
|
||||
throw new Error(`session context not found for ${context.sessionID}`);
|
||||
}
|
||||
const skillPath = normalizeSkillPath(args.skill_path);
|
||||
if (!skillPath) {
|
||||
if (sessionContext.allowLearningWrite === false && args.action !== "list") {
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "skill",
|
||||
decision: "rejected",
|
||||
detail:
|
||||
"invalid skill_path; expected a relative path under .opencode/skills",
|
||||
detail: "skill writes are disabled for this session",
|
||||
});
|
||||
}
|
||||
const pattern = sanitizePersistentLine(args.reason, 320);
|
||||
if (!pattern) {
|
||||
if (args.action === "list") {
|
||||
const result = await skillStore.list(args.skill_path);
|
||||
if (!result) {
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "skill",
|
||||
decision: "rejected",
|
||||
detail:
|
||||
"invalid skill_path; expected a relative path under .opencode/skills",
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "skill",
|
||||
decision: "rejected",
|
||||
detail: "reason rejected by persistence policy",
|
||||
decision: "accepted",
|
||||
detail: "skill listed",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await appendLearnedSkillPattern(skillPath, pattern);
|
||||
const result =
|
||||
args.action === "append_pattern"
|
||||
? await skillStore.appendPattern(args.skill_path, args.pattern ?? "")
|
||||
: args.action === "remove_pattern"
|
||||
? await skillStore.removePattern(args.skill_path, args.target_id ?? "")
|
||||
: args.action === "write_reference"
|
||||
? await skillStore.writeReference(
|
||||
args.skill_path,
|
||||
args.file_path ?? "",
|
||||
args.content ?? "",
|
||||
)
|
||||
: await skillStore.removeReference(args.skill_path, args.file_path ?? "");
|
||||
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
kind: "skill",
|
||||
decision: result.changed ? "accepted" : "deduped",
|
||||
detail: result.changed ? "skill file updated" : "pattern already existed",
|
||||
decision: result.changed ? "accepted" : "rejected",
|
||||
detail: result.detail,
|
||||
target: result.target,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const appendLearnedSkillPattern = async (
|
||||
skillPath: string,
|
||||
pattern: string,
|
||||
) => {
|
||||
return serializeWrite(async () => {
|
||||
const target = join(SKILLS_ROOT_DIR, skillPath, "SKILL.md");
|
||||
const current =
|
||||
(await readTextFile(target)) ?? defaultLearnedSkill(skillPath);
|
||||
const existingPatterns = extractLearnedPatterns(current);
|
||||
if (existingPatterns.includes(pattern)) {
|
||||
return { changed: false, target };
|
||||
}
|
||||
|
||||
const next = current.includes(LEARNED_PATTERNS_MARKER)
|
||||
? current.replace(
|
||||
LEARNED_PATTERNS_MARKER,
|
||||
`${LEARNED_PATTERNS_MARKER}\n- ${pattern}`,
|
||||
)
|
||||
: `${current.trimEnd()}\n\n${LEARNED_PATTERNS_MARKER}\n- ${pattern}\n`;
|
||||
|
||||
await ensureDirectory(join(SKILLS_ROOT_DIR, skillPath));
|
||||
// 追加 learned pattern 前先备份旧版 SKILL.md,避免共享技能被异常写坏。
|
||||
await atomicWriteFileWithHistory(target, next, {
|
||||
historyDir: SKILLS_HISTORY_DIR,
|
||||
rootDir: SKILLS_ROOT_DIR,
|
||||
});
|
||||
return { changed: true, target };
|
||||
});
|
||||
};
|
||||
|
||||
const serializeWrite = async <T>(task: () => Promise<T>) => {
|
||||
const run = writeQueue.catch(() => undefined).then(task);
|
||||
writeQueue = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return run;
|
||||
};
|
||||
|
||||
const defaultLearnedSkill = (skillPath: string) => `---
|
||||
name: tjwater-action-${toSkillName(skillPath)}
|
||||
description: 由 skill_manager 在线追加的高置信度可复用 workflow。
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# learned skill
|
||||
|
||||
## 简介
|
||||
|
||||
记录由 \`skill_manager\` 在线追加的高置信度 workflow 模式。
|
||||
|
||||
## Learned Patterns
|
||||
`;
|
||||
|
||||
const normalizeSkillPath = (rawSkillPath: string) => {
|
||||
const normalized = posix.normalize(
|
||||
rawSkillPath.trim().replace(/^\/+|\/+$/g, ""),
|
||||
);
|
||||
if (!normalized || normalized === "." || normalized.startsWith("..")) {
|
||||
return null;
|
||||
}
|
||||
if (normalized === "SKILL.md" || normalized.endsWith("/SKILL.md")) {
|
||||
return null;
|
||||
}
|
||||
if (!/^[a-z0-9._/-]+$/i.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const toSkillName = (skillPath: string) =>
|
||||
skillPath
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.join("-")
|
||||
.replace(/[^a-z0-9._-]+/gi, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 120) || "generated-skill";
|
||||
|
||||
const extractLearnedPatterns = (content: string) => {
|
||||
if (!content.includes(LEARNED_PATTERNS_MARKER)) {
|
||||
return [];
|
||||
}
|
||||
return (content.split(LEARNED_PATTERNS_MARKER)[1] ?? "")
|
||||
.split("\n")
|
||||
.filter((line) => line.trim().startsWith("- "))
|
||||
.map((line) => line.trim().slice(2));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user