LLM-driven 设计,添加学习审计和会话历史存储至目录的功能

This commit is contained in:
2026-05-15 11:50:20 +08:00
parent 2ba4f35a2d
commit 4ec6cbed16
15 changed files with 1557 additions and 133 deletions
+62 -9
View File
@@ -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,
});
},
+43
View File
@@ -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;
},
});
+60 -115
View File
@@ -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));
};