Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions assets/agents/team/architect.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,7 @@ permission:
- 接口定义必须完整(请求参数、响应结构、错误码)
- 每个任务应是垂直切片,单人 2-4 小时可完成
- 标注方案中的假设和不确定项

## Project Context

项目根 CONTEXT.md 是领域术语权威(消息中已自动注入内容与 digest)。遵循其中定义的领域术语;发现新术语或冲突时暂停提问。
4 changes: 4 additions & 0 deletions assets/agents/team/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,7 @@ self-report 每个 cycle 的状态,不跳过任何阶段。
- 不修改与任务无关的文件
- 不引入未在项目中使用的第三方依赖
- 不提交硬编码的密钥/配置

## Project Context

项目根 CONTEXT.md 是领域术语权威(消息中已自动注入内容与 digest)。遵循其中定义的领域术语;发现新术语或冲突时暂停提问。
4 changes: 4 additions & 0 deletions assets/agents/team/goal-verify.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,7 @@ permission:
---

不创建或修改任何文件。你是只读验证者。

## Project Context

项目根 CONTEXT.md 是领域术语权威(消息中已自动注入内容与 digest)。遵循其中定义的领域术语;发现新术语或冲突时暂停提问。
4 changes: 4 additions & 0 deletions assets/agents/team/reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,7 @@ permission:
- 不修改代码
- 每个问题必须给出具体的修复建议
- 优先关注安全性和正确性

## Project Context

项目根 CONTEXT.md 是领域术语权威(消息中已自动注入内容与 digest)。遵循其中定义的领域术语;发现新术语或冲突时暂停提问。
107 changes: 107 additions & 0 deletions src/kernel/context.ts
Original file line number Diff line number Diff line change
@@ -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<ContextBlock | null> {
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)",
`<!-- ${CONTEXT_MARKER} -->`,
`来源: ${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}`
}
47 changes: 41 additions & 6 deletions src/plugin/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
const errorRetryCount = new Map<string, number>()
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<string, unknown> }
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }) {
Expand Down
129 changes: 129 additions & 0 deletions test/kernel/context.test.ts
Original file line number Diff line number Diff line change
@@ -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("")
})
})
Loading
Loading