diff --git a/packages/v2/package.json b/packages/v2/package.json index ae40490..bc43360 100644 --- a/packages/v2/package.json +++ b/packages/v2/package.json @@ -6,7 +6,8 @@ "main": "./dist/index.js", "scripts": { "build": "tsup", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "smoke": "node smoke.mjs" }, "license": "MIT", "dependencies": { diff --git a/packages/v2/smoke.mjs b/packages/v2/smoke.mjs new file mode 100644 index 0000000..db54598 --- /dev/null +++ b/packages/v2/smoke.mjs @@ -0,0 +1,115 @@ +import assert from "node:assert/strict" +import { readFile, rm } from "node:fs/promises" +import { join } from "node:path" +import { homedir, tmpdir } from "node:os" + +const { default: plugin } = await import("./dist/index.js") +const sid = "v2-smoke-" + Date.now() + +const tools = [] +let contextHook = null +const ctx = { + options: { preserveRecentMessages: 1, coreOverrides: { preserveRecentTokens: 0 } }, + tool: { transform: async (cb) => { cb({ add: (t) => tools.push(t) }); return { dispose: async () => {} } } }, + session: { hook: async (name, cb) => { if (name === "context") contextHook = cb; return { dispose: async () => {} } } }, + catalog: { model: { list: async () => ({ data: [{ id: "test-model", providerID: "test", limit: { context: 200000 } }] }) } }, +} + +const teardown = await plugin.setup(ctx) +assert.equal(plugin.id, "billion-context-opencode-v2") + +const expected = ["bili_compress", "bili_decompress", "bili_search", "bili_status"] +const names = tools.map((t) => t.name) +assert.deepEqual(names, expected) +for (const t of tools) { + assert.equal(typeof t.description, "string") + assert.equal(t.input?.type, "object", `${t.name} has a JSON-schema input`) + assert.equal(typeof t.execute, "function", `${t.name} has an execute function`) +} +console.log("✓ plugin id:", plugin.id) +console.log("✓ tools registered with V2 shape (name / description / input / execute):", names.join(", ")) + +function userMsg(id, text) { + return { id, role: "user", content: [{ type: "text", text }] } +} +function assistantMsg(id, text) { + return { id, role: "assistant", content: [{ type: "text", text }] } +} + +const runHook = async (event) => { + await contextHook(event) + return event.messages +} +const extractRef = (msg) => { + for (const part of msg.content ?? []) { + if (part.type !== "text" || typeof part.text !== "string") continue + const m = part.text.match(/]*>(m\d+)<\/acp>/) + if (m) return m[1] + } + return null +} +const textOf = (msgs) => msgs.flatMap((m) => (m.content ?? []).filter((p) => p.type === "text").map((p) => p.text)).join("\n") + +const u1 = userMsg("u1", "first user turn about topic A. " + "alpha ".repeat(1200)) +const a1 = assistantMsg("a1", "assistant reply about A. " + "beta ".repeat(1200)) +const u2 = userMsg("u2", "second user turn about topic B. " + "gamma ".repeat(1200)) +const a2 = assistantMsg("a2", "assistant reply about B. " + "delta ".repeat(1200)) +const u3 = userMsg("u3", "recent question: what is the status?") + +const ev1 = { sessionID: sid, model: { id: "test-model", providerID: "test" }, system: [], messages: [u1, a1, u2, a2, u3], tools: {} } +let msgs = await runHook(ev1) +assert.equal(msgs.length, 5) +assert.ok(ev1.system.some((p) => p.type === "text" && p.text.includes("ACP TOOLS (billion-context)")), "system marker present") +const u2Ref = extractRef(msgs[2]) +const a2Ref = extractRef(msgs[3]) +assert.ok(u2Ref && a2Ref, "refs extractable") +console.log("✓ context hook ran, msgs:", msgs.length, "| refs:", u2Ref, a2Ref) + +await runHook(ev1) +const markerCount = ev1.system.filter((p) => p.type === "text" && p.text.includes("ACP TOOLS (billion-context)")).length +assert.equal(markerCount, 1, "system marker not duplicated") +const textPartCount = ev1.messages.flatMap((m) => (m.content ?? []).filter((p) => p.type === "text")).length +const tagCount = (textOf(ev1.messages).match(/ t.name === "bili_status") +const statusRes = await statusTool.execute({}, { sessionID: sid }) +assert.equal(typeof statusRes.content, "string") +console.log("✓ bili_status -> { content: string } (" + statusRes.content.length + " chars)") + +const compressTool = tools.find((t) => t.name === "bili_compress") +const compressRes = await compressTool.execute( + { content: [{ startId: u2Ref, endId: a2Ref, summary: "Second user turn about topic B, gamma/delta content.", topic: "v2-smoke" }] }, + { sessionID: sid, id: "call-v2-1" }, +) +assert.equal(typeof compressRes.content, "string") +assert.ok(compressRes.content.includes("bili ACP"), "compress summary line") +console.log("✓ bili_compress -> { content } (" + compressRes.content.length + " chars)") + +const statePath = join(homedir(), ".cache", "opencode-bili-acp", `${sid}.acp.json`) +const state = JSON.parse(await readFile(statePath, "utf8")) +assert.equal(state.blocks[0]?.compressCallId, "call-v2-1", "compressCallId mapped from V2 ctx.id") +console.log("✓ compressCallId mapped from V2 ctx.id:", state.blocks[0].compressCallId) + +const ev2 = { sessionID: sid, model: { id: "test-model", providerID: "test" }, system: [], messages: [u1, a1, u2, a2, u3], tools: {} } +msgs = await runHook(ev2) +assert.ok(textOf(msgs).includes("[Compressed conversation section]"), "summary placeholder present") +assert.ok(!textOf(msgs).includes("gamma gamma"), "compressed content pruned") +console.log("✓ after compress: summary present, old content pruned, msgs:", msgs.length) + +const searchTool = tools.find((t) => t.name === "bili_search") +const searchRes = await searchTool.execute({ query: "topic B gamma" }, { sessionID: sid }) +assert.equal(typeof searchRes.content, "string") +assert.ok(/block b\d|b\d/.test(searchRes.content), "search found the block") +console.log("✓ bili_search -> { content }") + +const decompTool = tools.find((t) => t.name === "bili_decompress") +const decompRes = await decompTool.execute({ blockId: "b1", inline: true }, { sessionID: sid }) +assert.equal(typeof decompRes.content, "string") +assert.ok(decompRes.content.includes("topic B"), "decompressed content restored") +console.log("✓ bili_decompress -> { content }") + +await teardown() +await rm(statePath, { force: true }) +console.log("\n=== ALL V2 SMOKE TESTS PASSED ===") diff --git a/packages/v2/src/index.ts b/packages/v2/src/index.ts index fe7d06e..1ce210a 100644 --- a/packages/v2/src/index.ts +++ b/packages/v2/src/index.ts @@ -7,10 +7,6 @@ import { warn, estimateTokens, collectCoveredMessageIds, - makeCompressTool, - makeDecompressTool, - makeSearchTool, - makeStatusTool, SYSTEM_PROMPT, numOpt, strArrayOpt, @@ -22,8 +18,14 @@ import { makeNudgeMessage, type V2Message, } from "./messages.js" +import { + makeV2CompressTool, + makeV2DecompressTool, + makeV2SearchTool, + makeV2StatusTool, +} from "./tools.js" -const SYSTEM_MARKER = "BILI CONTEXT MANAGEMENT" +const SYSTEM_MARKER = "ACP TOOLS (billion-context)" interface ModelRef { id?: string @@ -101,10 +103,10 @@ export default { await ctx.tool.transform((tools) => { const opts = { codemode: false, permission: "allow" } - tools.add({ ...makeCompressTool(runtime), options: opts }) - tools.add({ ...makeDecompressTool(runtime), options: opts }) - tools.add({ ...makeSearchTool(runtime), options: opts }) - tools.add({ ...makeStatusTool(runtime), options: opts }) + tools.add({ ...makeV2CompressTool(runtime), options: opts }) + tools.add({ ...makeV2DecompressTool(runtime), options: opts }) + tools.add({ ...makeV2SearchTool(runtime), options: opts }) + tools.add({ ...makeV2StatusTool(runtime), options: opts }) }) await ctx.session.hook("context", async (event: ContextEvent) => { diff --git a/packages/v2/src/tools.ts b/packages/v2/src/tools.ts new file mode 100644 index 0000000..7efa243 --- /dev/null +++ b/packages/v2/src/tools.ts @@ -0,0 +1,138 @@ +import { + makeCompressTool, + makeDecompressTool, + makeSearchTool, + makeStatusTool, + type AcpRuntime, + type ToolContext, + type ToolDef, +} from "@bili/core" + +/** opencode2 tool-execution context (structural subset of @opencode-ai/plugin's + * ToolContext). `id` is the tool-call id; V1's `callID` maps onto it. */ +export interface V2ToolContext { + sessionID: string + agent?: string + messageID?: string + id?: string + progress?: (update: unknown) => Promise +} + +/** opencode2 tool shape: `Info` = { name, description, input (schema), execute }. */ +export interface V2ToolInfo { + name: string + description: string + input: Record + execute(input: Record, ctx: V2ToolContext): Promise<{ content: string }> +} + +/** opencode2 does not provide directory/worktree; V1 ToolContext requires them. */ +function toV1Context(ctx: V2ToolContext): ToolContext { + return { + sessionID: ctx.sessionID, + messageID: ctx.messageID ?? "", + callID: ctx.id, + agent: ctx.agent ?? "", + directory: "", + worktree: "", + } +} + +async function run(tool: ToolDef, input: Record, ctx: V2ToolContext): Promise<{ content: string }> { + const result = await tool.execute(input, toV1Context(ctx)) + return { content: typeof result === "string" ? result : result.output } +} + +export function makeV2CompressTool(runtime: AcpRuntime): V2ToolInfo { + const tool = makeCompressTool(runtime) + return { + name: "bili_compress", + description: tool.description, + input: { + type: "object", + properties: { + topic: { type: "string", description: "Fallback topic for entries without their own." }, + content: { + type: "array", + description: "One or more ranges to compress, each with start/end boundaries and a summary.", + items: { + type: "object", + properties: { + startId: { type: "string", description: 'Message ref, e.g. "m00005" (from the bili tag), or a block id "b3".' }, + endId: { type: "string", description: "Inclusive end ref. Must be at or after startId." }, + summary: { type: "string", description: "Complete technical summary replacing all content in range. Keep only essential details (conclusions, file paths, signatures, decisions, exact values)." }, + topic: { type: "string", description: "Short label (3-5 words) for THIS range. Omit to use top-level topic." }, + }, + required: ["startId", "endId", "summary"], + }, + }, + summaryMaxChars: { type: "number", description: "Override max summary length (default 20000). Use when content needs more detail." }, + }, + required: ["content"], + }, + execute(input, ctx) { + return run(tool, input, ctx) + }, + } +} + +export function makeV2DecompressTool(runtime: AcpRuntime): V2ToolInfo { + const tool = makeDecompressTool(runtime) + return { + name: "bili_decompress", + description: tool.description, + input: { + type: "object", + properties: { + blockId: { type: "string", description: 'Block id to restore, e.g. "b5". Also accepts a message ref from bili_search results — resolves to the owning block automatically.' }, + full: { type: "boolean", description: "Recurse through all nested blocks to original messages. Default: false (one tier up)." }, + toFile: { type: "string", description: "Write restored content to this path (must be under /tmp, ~/.cache/opencode, or ~/.cache/opencode-bili-acp)." }, + inline: { type: "boolean", description: "Return content inline as this tool result. Default: false for blocks (file), true for single messages." }, + }, + required: ["blockId"], + }, + execute(input, ctx) { + return run(tool, input, ctx) + }, + } +} + +export function makeV2SearchTool(runtime: AcpRuntime): V2ToolInfo { + const tool = makeSearchTool(runtime) + return { + name: "bili_search", + description: tool.description, + input: { + type: "object", + properties: { + query: { type: "string", description: "Keywords to locate detail folded into compressed summaries or historical messages." }, + limit: { type: "number", description: "Max results (default 10)." }, + }, + required: ["query"], + }, + execute(input, ctx) { + return run(tool, input, ctx) + }, + } +} + +export function makeV2StatusTool(runtime: AcpRuntime): V2ToolInfo { + const tool = makeStatusTool(runtime) + return { + name: "bili_status", + description: tool.description, + input: { + type: "object", + properties: { + scope: { type: "string", enum: ["compressed", "uncompressed"], description: '"compressed" = drill into blocks; "uncompressed" = show visible messages/ranges. Default: overview.' }, + view: { type: "string", enum: ["ranges", "messages"], description: 'For uncompressed scope: "ranges" (default) or "messages".' }, + tool: { type: "string", description: 'Filter by tool name (e.g. "bash", "read"). uncompressed+messages only.' }, + sort: { type: "string", enum: ["size", "time", "tool", "age"], description: "Sort order. Default: size." }, + limit: { type: "number", description: "Max items to show (default 30)." }, + }, + }, + execute(input, ctx) { + return run(tool, input, ctx) + }, + } +}