diff --git a/assets/agents/team/architect.md b/assets/agents/team/architect.md index c02643e..bd6262c 100644 --- a/assets/agents/team/architect.md +++ b/assets/agents/team/architect.md @@ -79,3 +79,7 @@ permission: - 接口定义必须完整(请求参数、响应结构、错误码) - 每个任务应是垂直切片,单人 2-4 小时可完成 - 标注方案中的假设和不确定项 + +## Project Context + +项目根 CONTEXT.md 是领域术语权威(消息中已自动注入内容与 digest)。遵循其中定义的领域术语;发现新术语或冲突时暂停提问。 diff --git a/assets/agents/team/backend.md b/assets/agents/team/backend.md index 8b92fa8..8d8b7d0 100644 --- a/assets/agents/team/backend.md +++ b/assets/agents/team/backend.md @@ -98,3 +98,7 @@ self-report 每个 cycle 的状态,不跳过任何阶段。 - 不修改与任务无关的文件 - 不引入未在项目中使用的第三方依赖 - 不提交硬编码的密钥/配置 + +## Project Context + +项目根 CONTEXT.md 是领域术语权威(消息中已自动注入内容与 digest)。遵循其中定义的领域术语;发现新术语或冲突时暂停提问。 diff --git a/assets/agents/team/goal-verify.md b/assets/agents/team/goal-verify.md index b7d66da..d259241 100644 --- a/assets/agents/team/goal-verify.md +++ b/assets/agents/team/goal-verify.md @@ -52,3 +52,7 @@ permission: --- 不创建或修改任何文件。你是只读验证者。 + +## Project Context + +项目根 CONTEXT.md 是领域术语权威(消息中已自动注入内容与 digest)。遵循其中定义的领域术语;发现新术语或冲突时暂停提问。 diff --git a/assets/agents/team/reviewer.md b/assets/agents/team/reviewer.md index 18ee39d..ecc0cf9 100644 --- a/assets/agents/team/reviewer.md +++ b/assets/agents/team/reviewer.md @@ -115,3 +115,7 @@ permission: - 不修改代码 - 每个问题必须给出具体的修复建议 - 优先关注安全性和正确性 + +## Project Context + +项目根 CONTEXT.md 是领域术语权威(消息中已自动注入内容与 digest)。遵循其中定义的领域术语;发现新术语或冲突时暂停提问。 diff --git a/src/kernel/context.ts b/src/kernel/context.ts new file mode 100644 index 0000000..4984c2c --- /dev/null +++ b/src/kernel/context.ts @@ -0,0 +1,107 @@ +import { createHash } from "node:crypto" +import { readFile, stat } from "node:fs/promises" +import path from "node:path" + +/** 注入块内的防重复 marker(message transform 据此跳过已注入的会话) */ +export const CONTEXT_MARKER = "cabbage-project-context" + +/** CONTEXT.md 全文注入的行数上限,超长截断并提示 */ +export const MAX_CONTEXT_LINES = 200 + +export interface ContextDigest { + /** 根 CONTEXT.md 的绝对路径 */ + path: string + /** 文件 mtime(毫秒),用于缓存刷新判定 */ + mtimeMs: number + /** sha256(内容) 前 16 位十六进制 */ + digest: string + /** 从 CONTEXT.md 提取的 ## 标题列表 */ + terms: string[] +} + +export interface ContextBlock extends ContextDigest { + /** 可直接注入到 prompt 的块文本 */ + block: string +} + +let _cache: { projectDir: string; mtimeMs: number; content: string } | null = null + +export function resetContextCache(): void { + _cache = null +} + +/** + * 发现并摘要项目根 CONTEXT.md。 + * - 缺失 → 返回 null(不报错) + * - mtime 变化才重读,否则命中缓存 + */ +export async function getContextBlock(projectDir: string): Promise { + const filePath = path.join(projectDir, "CONTEXT.md") + let stats + try { + stats = await stat(filePath) + } catch { + _cache = null + return null + } + + if (_cache?.projectDir === projectDir && _cache.mtimeMs === stats.mtimeMs) { + return buildContextBlock(projectDir, filePath, stats.mtimeMs, _cache.content) + } + + const content = await readFile(filePath, "utf8") + _cache = { projectDir, mtimeMs: stats.mtimeMs, content } + return buildContextBlock(projectDir, filePath, stats.mtimeMs, content) +} + +/** 取注入块文本;无块时返回空串(调用方无需判空) */ +export function formatContextBlock(block: ContextBlock | null): string { + return block?.block ?? "" +} + +function buildContextBlock(projectDir: string, filePath: string, mtimeMs: number, content: string): ContextBlock { + const digest = createHash("sha256").update(content).digest("hex").slice(0, 16) + const terms = extractTerms(content) + return { + path: filePath, + mtimeMs, + digest, + terms, + block: renderBlock(projectDir, filePath, mtimeMs, digest, terms, content), + } +} + +function extractTerms(content: string): string[] { + const terms: string[] = [] + for (const line of content.split("\n")) { + const match = /^##\s+(.+)$/.exec(line) + if (match) terms.push(match[1].trim()) + } + return terms +} + +function renderBlock( + projectDir: string, + filePath: string, + mtimeMs: number, + digest: string, + terms: string[], + content: string, +): string { + const lines = content.split("\n") + const truncated = lines.length > MAX_CONTEXT_LINES + const body = truncated ? lines.slice(0, MAX_CONTEXT_LINES).join("\n") : content + + const header = [ + "## Project Context (auto-injected)", + ``, + `来源: ${path.relative(projectDir, filePath)} · mtime: ${new Date(mtimeMs).toISOString()} · digest: ${digest}`, + terms.length > 0 ? `主题: ${terms.join(", ")}` : "", + ].filter(Boolean).join("\n") + + const truncationNote = truncated + ? `\n\n> ⚠️ CONTEXT.md 超过 ${MAX_CONTEXT_LINES} 行,已截断。请直接读取完整文件。` + : "" + + return `${header}\n\n${body}${truncationNote}` +} diff --git a/src/plugin/server.ts b/src/plugin/server.ts index 70ebe58..6dcbdf0 100644 --- a/src/plugin/server.ts +++ b/src/plugin/server.ts @@ -21,11 +21,35 @@ import { } from "../flowrun/transitions.js" import type { TaskExecutionBinding, FlowControlResponse } from "../flowrun/types.js" import { readFlowRun } from "../flowrun/github.js" +import { getContextBlock, formatContextBlock, CONTEXT_MARKER } from "../kernel/context.js" +import type { ContextBlock } from "../kernel/context.js" const abortedSessions = new Set() const errorRetryCount = new Map() const COMPACTION_THRESHOLD = 20 +export interface FirstUserInjectionInput { + hasGoal: boolean + isSubAgent: boolean + contextBlock: ContextBlock | null + bootstrap: string +} + +/** + * 决定首条 user 消息注入内容: + * - Primary(goal 激活)→ bootstrap + Project Context 块 + * - 子 agent → 仅 Project Context 块 + * - 其他 → 不注入 + */ +export function buildFirstUserInjection(input: FirstUserInjectionInput): string | null { + const parts: string[] = [] + if (input.hasGoal) parts.push(input.bootstrap) + if (input.contextBlock && (input.hasGoal || input.isSubAgent)) { + parts.push(input.contextBlock.block) + } + return parts.length > 0 ? parts.join("\n\n") : null +} + interface V1ClientContainer { _client?: { getConfig?: () => Record } } @@ -120,9 +144,12 @@ async function queueContinuation( } catch {} } + const contextBlock = projectDir ? await getContextBlock(projectDir) : null + const contextText = contextBlock ? `${formatContextBlock(contextBlock)}\n\n` : "" + await client.session.promptAsync({ sessionID, - parts: [{ type: "text" as const, text: continuationPrompt(goal.objective, goal.completionCriterion), synthetic: true }], + parts: [{ type: "text" as const, text: contextText + continuationPrompt(goal.objective, goal.completionCriterion), synthetic: true }], }) } catch (err) { console.error("[cabbage] continuation failed:", err) @@ -538,13 +565,21 @@ export function createOpencodeCabbage(packageRoot: string): Plugin { const firstUser = output.messages.find(m => m.info.role === "user") if (!firstUser || !firstUser.parts.length) return - if (firstUser.parts.some(p => p.type === "text" && p.text.includes("EXTREMELY_IMPORTANT"))) return + if (firstUser.parts.some(p => p.type === "text" && (p.text.includes("EXTREMELY_IMPORTANT") || p.text.includes(CONTEXT_MARKER)))) return - // Only inject bootstrap for active flow: goal is active or user sent a flow command - const hasGoal = await readGoal(goalClient, output.messages[0].info.sessionID).then(r => r.goal?.status === "active").catch(() => false) - if (!hasGoal) return + const sessionID = output.messages[0].info.sessionID as string + const { goal, session } = await readGoal(goalClient, sessionID) + const contextBlock = await getContextBlock(projectDir) - firstUser.parts.unshift({ type: "text", text: getBootstrapContent() } as typeof firstUser.parts[number]) + const injection = buildFirstUserInjection({ + hasGoal: goal?.status === "active", + isSubAgent: !!session?.parentID, + contextBlock, + bootstrap: getBootstrapContent(), + }) + if (injection) { + firstUser.parts.unshift({ type: "text", text: injection } as typeof firstUser.parts[number]) + } }, async event({ event }) { diff --git a/test/kernel/context.test.ts b/test/kernel/context.test.ts new file mode 100644 index 0000000..9c3d0ba --- /dev/null +++ b/test/kernel/context.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { createHash } from "node:crypto" +import { mkdtemp, stat, utimes, writeFile, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +import { + getContextBlock, + resetContextCache, + formatContextBlock, + CONTEXT_MARKER, + MAX_CONTEXT_LINES, +} from "../../src/kernel/context.js" + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), "cabbage-context-")) + resetContextCache() +}) + +afterEach(async () => { + resetContextCache() + await rm(tmpDir, { recursive: true, force: true }) +}) + +function sha16(content: string): string { + return createHash("sha256").update(content).digest("hex").slice(0, 16) +} + +describe("getContextBlock — 发现与摘要", () => { + it("发现根 CONTEXT.md 并返回 path/mtime/digest/terms", async () => { + const content = "## Terms\n- user\n- agent\n" + await writeFile(path.join(tmpDir, "CONTEXT.md"), content, "utf8") + + const block = await getContextBlock(tmpDir) + + expect(block).not.toBeNull() + expect(block!.path).toBe(path.join(tmpDir, "CONTEXT.md")) + expect(block!.mtimeMs).toBeGreaterThan(0) + expect(block!.digest).toBe(sha16(content)) + expect(block!.terms).toEqual(["Terms"]) + }) + + it("CONTEXT.md 缺失时返回 null 且不报错", async () => { + expect(await getContextBlock(tmpDir)).toBeNull() + }) + + it("digest 稳定:同内容两次调用 digest 一致", async () => { + await writeFile(path.join(tmpDir, "CONTEXT.md"), "## Terms\nsame\n", "utf8") + + const first = await getContextBlock(tmpDir) + const second = await getContextBlock(tmpDir) + + expect(first!.digest).toBe(second!.digest) + }) + + it("提取全部 ## 标题为 terms", async () => { + await writeFile(path.join(tmpDir, "CONTEXT.md"), "## Terms\n## Roles\n## Workflow\n", "utf8") + + const block = await getContextBlock(tmpDir) + + expect(block!.terms).toEqual(["Terms", "Roles", "Workflow"]) + }) +}) + +describe("getContextBlock — mtime 缓存刷新", () => { + it("mtime 变化后重读并刷新 digest", async () => { + const file = path.join(tmpDir, "CONTEXT.md") + await writeFile(file, "## v1\n", "utf8") + const first = await getContextBlock(tmpDir) + + await writeFile(file, "## v2\nnew term\n", "utf8") + const stats = await stat(file) + await utimes(file, stats.atime, new Date(stats.mtimeMs + 5000)) + + const second = await getContextBlock(tmpDir) + + expect(second!.digest).not.toBe(first!.digest) + expect(second!.terms).toEqual(["v2"]) + }) + + it("mtime 不变时命中缓存(内容变化也不重读)", async () => { + const file = path.join(tmpDir, "CONTEXT.md") + const fixedMtime = new Date(1_000_000_000_000) // 固定整数毫秒,可精确还原 + await writeFile(file, "## cached\n", "utf8") + await utimes(file, new Date(), fixedMtime) + const first = await getContextBlock(tmpDir) + + // 内容变化但把 mtime 还原 → 应命中缓存 + await writeFile(file, "## changed\n", "utf8") + await utimes(file, new Date(), fixedMtime) + + const second = await getContextBlock(tmpDir) + + expect(second!.digest).toBe(first!.digest) + }) +}) + +describe("formatContextBlock — 注入块格式", () => { + it("包含 marker、来源、digest 与全文", async () => { + await writeFile(path.join(tmpDir, "CONTEXT.md"), "## Terms\n- user\n", "utf8") + const block = await getContextBlock(tmpDir) + + const text = formatContextBlock(block!) + + expect(text).toContain(CONTEXT_MARKER) + expect(text).toContain("Project Context (auto-injected)") + expect(text).toContain("CONTEXT.md") + expect(text).toContain(block!.digest) + expect(text).toContain("## Terms") + expect(text).toContain("- user") + }) + + it("超长文件截断并提示", async () => { + const lines = Array.from({ length: MAX_CONTEXT_LINES + 50 }, (_, i) => `line ${i}`) + await writeFile(path.join(tmpDir, "CONTEXT.md"), lines.join("\n"), "utf8") + const block = await getContextBlock(tmpDir) + + const text = formatContextBlock(block!) + + expect(text).toContain("截断") + expect(text).not.toContain(`line ${MAX_CONTEXT_LINES + 49}`) + }) + + it("CONTEXT.md 缺失时 format 返回空字符串", () => { + expect(formatContextBlock(null)).toBe("") + }) +}) diff --git a/test/plugin/server-context.test.ts b/test/plugin/server-context.test.ts new file mode 100644 index 0000000..b1ac2b2 --- /dev/null +++ b/test/plugin/server-context.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { mkdtemp, writeFile, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const mockSessionGet = vi.fn() +const mockPromptAsync = vi.fn() + +vi.mock("../../src/plugin/goal.js", async () => { + const actual = await vi.importActual("../../src/plugin/goal.js") + return { + ...actual, + createGoalClient: () => ({ + session: { + get: (...args: unknown[]) => mockSessionGet(...args), + update: vi.fn(), + promptAsync: (...args: unknown[]) => mockPromptAsync(...args), + }, + }), + } +}) + +import { createOpencodeCabbage, buildFirstUserInjection } from "../../src/plugin/server.js" +import { resetContextCache, CONTEXT_MARKER } from "../../src/kernel/context.js" + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..") +const ACTIVE_GOAL = { + data: { + parentID: null, + metadata: { + goal: { objective: "test", completionCriterion: "pass", status: "active", continuationCount: 0 }, + }, + }, +} +const SUBAGENT_SESSION = { data: { parentID: "parent-1", metadata: {} } } +const PLAIN_SESSION = { data: { parentID: null, metadata: {} } } + +type Transform = (input: unknown, output: { messages: unknown[] }) => Promise + +let tmpDir: string +let transform: Transform + +async function makeOutput(sessionID = "sess-1", text = "hello") { + return { + messages: [ + { info: { role: "user", sessionID }, parts: [{ type: "text", text }] }, + ], + } +} + +beforeEach(async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), "cabbage-server-context-")) + resetContextCache() + process.env.CABBAGE_SKILLS_DIR = path.join(tmpDir, "skills") + mockSessionGet.mockReset() + mockPromptAsync.mockReset() + + const ctx = { + worktree: tmpDir, + directory: tmpDir, + client: { _client: { getConfig: () => ({}) } }, + serverUrl: new URL("http://localhost:0"), + } + const plugin = await createOpencodeCabbage(projectRoot)(ctx as never, {}) + transform = plugin["experimental.chat.messages.transform"] as Transform +}) + +afterEach(async () => { + resetContextCache() + delete process.env.CABBAGE_SKILLS_DIR + await rm(tmpDir, { recursive: true, force: true }) +}) + +describe("buildFirstUserInjection — 注入决策", () => { + const bootstrap = "bootstrap content" + const contextBlock = { + path: "/tmp/CONTEXT.md", + mtimeMs: 1, + digest: "abc123", + terms: ["Terms"], + block: `## Project Context (auto-injected)\n\n...`, + } + + it("Primary(goal 激活)注入 bootstrap + context", () => { + const text = buildFirstUserInjection({ hasGoal: true, isSubAgent: false, contextBlock, bootstrap }) + expect(text).toContain(bootstrap) + expect(text).toContain(CONTEXT_MARKER) + }) + + it("Primary 无 CONTEXT.md 时仅注入 bootstrap", () => { + const text = buildFirstUserInjection({ hasGoal: true, isSubAgent: false, contextBlock: null, bootstrap }) + expect(text).toBe(bootstrap) + }) + + it("子 agent 注入 context 但不注入 bootstrap", () => { + const text = buildFirstUserInjection({ hasGoal: false, isSubAgent: true, contextBlock, bootstrap }) + expect(text).toContain(CONTEXT_MARKER) + expect(text).not.toContain("EXTREMELY_IMPORTANT") + }) + + it("goal 未激活且非子 agent → 不注入", () => { + const text = buildFirstUserInjection({ hasGoal: false, isSubAgent: false, contextBlock, bootstrap }) + expect(text).toBeNull() + }) + + it("子 agent 无 CONTEXT.md → 不注入", () => { + const text = buildFirstUserInjection({ hasGoal: false, isSubAgent: true, contextBlock: null, bootstrap }) + expect(text).toBeNull() + }) +}) + +describe("message.transform — 注入接线", () => { + it("Primary 首消息注入 bootstrap + Project Context 块", async () => { + await writeFile(path.join(tmpDir, "CONTEXT.md"), "## Terms\n- user\n", "utf8") + mockSessionGet.mockResolvedValue(ACTIVE_GOAL) + + const output = await makeOutput() + await transform({}, output) + + const text = (output.messages[0].parts[0] as { text: string }).text + expect(text).toContain("EXTREMELY_IMPORTANT") + expect(text).toContain("Project Context (auto-injected)") + expect(text).toContain(CONTEXT_MARKER) + }) + + it("子 agent 首消息注入 Project Context 块(无 bootstrap)", async () => { + await writeFile(path.join(tmpDir, "CONTEXT.md"), "## Terms\n- agent\n", "utf8") + mockSessionGet.mockResolvedValue(SUBAGENT_SESSION) + + const output = await makeOutput() + await transform({}, output) + + const text = (output.messages[0].parts[0] as { text: string }).text + expect(text).toContain("Project Context (auto-injected)") + expect(text).toContain(CONTEXT_MARKER) + expect(text).not.toContain("EXTREMELY_IMPORTANT") + }) + + it("goal 未激活且非子 agent 不注入", async () => { + await writeFile(path.join(tmpDir, "CONTEXT.md"), "## Terms\n- user\n", "utf8") + mockSessionGet.mockResolvedValue(PLAIN_SESSION) + + const output = await makeOutput() + await transform({}, output) + + expect(output.messages[0].parts).toHaveLength(1) + expect((output.messages[0].parts[0] as { text: string }).text).toBe("hello") + }) + + it("CONTEXT.md 缺失时不报错,Primary 仍注入 bootstrap", async () => { + mockSessionGet.mockResolvedValue(ACTIVE_GOAL) + + const output = await makeOutput() + await transform({}, output) + + const text = (output.messages[0].parts[0] as { text: string }).text + expect(text).toContain("EXTREMELY_IMPORTANT") + expect(text).not.toContain(CONTEXT_MARKER) + }) + + it("已含注入 marker 的会话不重复注入", async () => { + await writeFile(path.join(tmpDir, "CONTEXT.md"), "## Terms\n- user\n", "utf8") + mockSessionGet.mockResolvedValue(ACTIVE_GOAL) + + const output = await makeOutput("sess-1", `already injected `) + await transform({}, output) + + expect(output.messages[0].parts).toHaveLength(1) + }) +})