Skip to content
Closed
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
3 changes: 2 additions & 1 deletion packages/v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"main": "./dist/index.js",
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"smoke": "node smoke.mjs"
},
"license": "MIT",
"dependencies": {
Expand Down
115 changes: 115 additions & 0 deletions packages/v2/smoke.mjs
Original file line number Diff line number Diff line change
@@ -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(/<acp [^>]*>(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(/<acp /g) ?? []).length
assert.equal(tagCount, textPartCount, "exactly one tag per text part (idempotent)")
console.log("✓ idempotent across dispatches")

const statusTool = tools.find((t) => 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 ===")
20 changes: 11 additions & 9 deletions packages/v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@ import {
warn,
estimateTokens,
collectCoveredMessageIds,
makeCompressTool,
makeDecompressTool,
makeSearchTool,
makeStatusTool,
SYSTEM_PROMPT,
numOpt,
strArrayOpt,
Expand All @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down
138 changes: 138 additions & 0 deletions packages/v2/src/tools.ts
Original file line number Diff line number Diff line change
@@ -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<void>
}

/** opencode2 tool shape: `Info` = { name, description, input (schema), execute }. */
export interface V2ToolInfo {
name: string
description: string
input: Record<string, unknown>
execute(input: Record<string, unknown>, 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<string, unknown>, 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)
},
}
}