This document is the highest-priority specification. All developers (including AI Agents) MUST comply.
billion-context is an npm package (bili CLI) — a context-compression proxy for AI agents. It sits between an agent client and an upstream LLM provider, injecting acp-kernel's compression pipeline to manage context growth.
| Category | Technology |
|---|---|
| Language | TypeScript (strict, ESM) |
| Build | tsup (bundling, inlines acp-kernel) |
| Test | Node.js built-in: node --import tsx --test tests/*.test.ts |
| Runtime Dep | acp-kernel (bundled at build time) + zod (external, only used by dist/agent/opencode-native.js V1 tools; dist/index.js stays dependency-free) |
| Field | Value |
|---|---|
| npm package | billion-context |
| CLI | bili / bili-proxy |
| GitHub | https://github.com/ranxianglei/billion-context |
| License | MIT |
billion-context/
├── src/
│ ├── index.ts # Entry: runs cli.ts main()
│ ├── cli.ts # CLI dispatcher: start/update/export/test/plugin + client launchers
│ ├── server.ts # HTTP proxy server, request pipeline
│ ├── config.ts # Config loading (file + env + CLI flags)
│ ├── logger.ts # Tee logger: file (~/.local/state/) + stderr
│ ├── paths.ts # XDG paths (config/cache/state dirs)
│ ├── session.ts # Session model + in-memory store
│ ├── session-id.ts # Session ID generation
│ ├── persist.ts # On-disk session persistence (kernel StateStore)
│ ├── update.ts # Auto-update: checks npm, auto-installs latest
│ ├── launcher.ts # `bili <client>` launchers (pi/codex/claude/omp/opencode/hermes/dsh/codebuddy/qoder/trae/jcode/kimi)
│ ├── client-config.ts # READ-only discovery of each client's upstream config
│ ├── mitm.ts / ca.ts # Cert-MITM proxying + lazily generated root CA
│ ├── mcp.ts # Plugin-in-launcher MCP shell (spawn-time injection)
│ ├── plugin.ts / plugin-install.ts # Cooperative plugin protocol + `bili plugin install`
│ ├── registry.ts # models.dev context-window registry (snapshot-first)
│ ├── registry-snapshot.json # Bundled full models.dev snapshot (offline floor)
│ ├── upstream-proxy.ts # undici ProxyAgent routing (https_proxy for registry fetch)
│ ├── stream.ts # SSE stream utilities + tag patching
│ ├── stream-openai.ts # OpenAI-format stream processing
│ ├── stream-responses.ts # Responses-API stream processing
│ ├── stream-error.ts # Stream error handling
│ ├── sse-util.ts # SSE parsing helpers
│ ├── loop/ # Unified compress loop (wire-agnostic core)
│ │ ├── core.ts # protocol-neutral event model + tool adjudication
│ │ ├── adapter-anthropic.ts # Anthropic wire adapter (buffer-to-finish tool calls)
│ │ ├── adapter-openai.ts # OpenAI chat adapter (buffer-to-finish, raw passthrough)
│ │ └── adapter-responses.ts # Responses API adapter
│ ├── compress-loop.ts # Compress loop (OpenAI chat format)
│ ├── compress-loop-responses.ts # Compress loop (Responses API format)
│ ├── compress-settings.ts # Three-level compress config merge
│ ├── compress-tool.ts # compress tool parsing (kernel parseCompressArgs)
│ ├── decompress-shared.ts # Shared decompress logic
│ ├── orphan-gc.ts # Orphaned block cleanup
│ ├── agent/ # Thin agent-side plugins (pi/omp/opencode; opencode-acp-command.ts = shared /acp hooks V1+V2, opencode-native.ts = self-spawn native, V1 `.server()` + V2 `setup`)
│ ├── web/ # Web UI (config + context windows)
│ ├── fetch-util.ts # HTTP fetch with timeout
│ └── util.ts # Misc utilities
├── tests/ # 66 test files
├── tsup.config.ts
└── package.json
- acp-kernel is bundled inline — tsup does NOT list it in
external, sodist/index.jsis self-contained. Exception:zod(exact4.1.8, matching the opencode host's own zod so V1 plugin-tool shapes interoperate) is a real dependency and stays external — onlydist/agent/opencode-native.jsimports it (lazily, at plugin-tool registration);dist/index.jsand every other entry remain zod-free. When zod cannot be resolved at runtime the V1 plugin degrades to plain proxy mode instead of failing. - Tags use XML format
<acp tokens="2" type="text">m00001</acp>— written with hex escapes (\x3c,\x3e) to avoid Write/Edit tool stripping - Auto-update: checks npm registry every 3 min (
CHECK_INTERVAL_MS = 3*60*1000), first check per process ignores throttle - Tee logger: all proxy logs go through
src/logger.ts(file + stderr). Do NOT useconsole.errorin server-side modules — useloggerLog(). - acp-kernel MUST be pinned to an exact version (e.g.
"acp-kernel": "0.0.17", NEVER"^0.0.17"). Because acp-kernel is a build-time dependency that tsup bundles inline intodist, a caret range makes the resolved version drift ifpackage-lock.jsonis regenerated or absent, breaking reproducible builds. When bumping acp-kernel: set the exact version inpackage.json, runnpm installto refresh the lockfile, then rebuild. Thepackage-lock.jsonis committed and kept in sync. - Single-writer plugin copies (#991) — every bili presence has exactly one writer. Host-managed copies (pi's npm entry, opencode's plugin dir, dsh profile bundles in pnpm's store) are NEVER overwritten in place by bili:
src/update.ts→hostManagedInstall()detects pnpm virtual-store (.pnpm) and host-home trees (pi/opencode/dsh/kimi/omp) and the self-updater skips them;installViaTarballrefuses them structurally. Reference lanes (omp/claude/codex/kimi) point at the global install and update with it.bili plugin update [agent]drives each lane through its own owner. Mixing user commands is fine (they share channels); mixing writers is what the guard forbids. - Two compression modes with different summary carriers —
pluginMode(thex-bili-pluginheader / registered agent, e.g.bili pi) means the ACP-native agent OWNS compression: it executescompresslocally, the call+result live in its own re-sent history, and the summary carrier on the wire is the tool call (the proxy suppresses tool + nudge injection; the agent's view never renders the kernel'sacp_summary). Proxy mode (plain client, no header) means the proxy executescompressserver-side: the tool call is ephemeral (never enters the client's history) and preflight blocks have none, so the summary carrier is theacp_summarymessage, which the kernel renders as rolesystembutsystemToUser(src/util.ts) re-voices as ausermessage (leaving it at its anchor) so strict backends (SGLang: exactly one system at index 0, #377) accept it and the head system message stays byte-stable for the prefix cache. The mode is decided per request and bound per session (session.metadata.pluginAgent, sticky, upgrade-only). See README "Two compression modes".
The kernel (acp-kernel) guarantees, and billion-context RELIES on: within a
session, a raw content-hash id and a ref number (mNNNNN) denote exactly one
message forever — never reused, never duplicated, even after the message
dies (edited/truncated/folded). The model can cite any number it has ever
seen (summaries cite tags across turns), so a re-issued number silently
misattributes on decompress. Consequences for this repo:
- Host code must NOT prune/repack
session.state.messageRefsin ways that let a freed number be re-issued (kernelassignRefsNodecomputes its cursor ashighestUsedIndex(map)+1, so shrinking the map can drop the cursor and re-issue numbers). - Known residual:
applyCompactionArchive(#421,src/session.ts) prunesbyRaw/byRefto live raw ids on native-compaction boundaries. In practice the highest-numbered (newest) messages stay resident so the cursor does not drop, but this is a theoretical re-issue window — drop the map-prune once the kernel's ref-space widening (post-#191 direction) makes it unnecessary. - Historical note: kernel 0.0.48/0.0.49 briefly contained ref-slot
reclamation (reverted in kernel #191, see
persist/store.ts). The guard "do not bump past 0.0.47" is obsolete — master pins 0.0.56.
npm run build # tsup bundle (inlines acp-kernel)
npm run typecheck # tsc --noEmit --project tsconfig.build.json
npm test # node --import tsx --test tests/*.test.tsnpm run build
npm install -g billion-context@latest # install from registry
bili start --port 8787npm install -g . also works (installs from the local directory) and does NOT
create a symlink here — npm copies the package into the global node_modules
because package.json has proper bin + files fields. Either approach is
fine; the registry install is preferred for testing the real published
artifact.
tests/e2e/e2e-codex.test.ts drives the real codex CLI through a built
proxy against a real Responses-compatible upstream and asserts the full
context lifecycle end-to-end: warmup → load growth → ACP compress → purity →
native-compact interception (last phase gated by E2E_FORGE=1). Full phase
details, env vars, and mechanics: tests/e2e/README.md.
# zero-token preflight (codex binary + dist + upstream reachable)
E2E_CHECK=1 node --import tsx --test tests/e2e/e2e-codex.test.ts
# full run (defaults to local sglang at http://127.0.0.1:8199/v1, zero cost)
npm run build
ACP_TEST_E2E=1 node --import tsx --test tests/e2e/e2e-codex.test.ts
# + native-compact interception phase
ACP_TEST_E2E=1 E2E_FORGE=1 node --import tsx --test tests/e2e/e2e-codex.test.tsRules:
- The suite skips by default so
npm teststays free; never remove theACP_TEST_E2Egate. - Run it (at least the 4-phase core) before merging changes to the request
pipeline —
server.ts,src/loop/, adapters, preflight/compact paths. It is the only coverage that exercises real client behavior (codex UA, wire quirks, retry loops). - Any Responses-compatible upstream works via
E2E_UPSTREAM_URL/E2E_UPSTREAM_KEY; the provider is configured asname = "OpenAI"so codex stays on the remote compaction (V2) path — do not "fix" this. - CI (
.github/workflows/ci-e2e.yml) is manual-dispatch only and needs repo secretsE2E_UPSTREAM_URL/E2E_UPSTREAM_KEY; a hosted runner cannot reach127.0.0.1upstreams.
- No
as any, No@ts-ignore - No comments unless absolutely necessary
- Hex escapes required for any
<acp>XML in source files - No
console.errorin server-side modules — useloggerLog(level, msg)fromsrc/logger.ts. The only exceptions aresrc/cli.ts(user-facing CLI errors) andsrc/index.ts(pre-logger startup crash).
| Rule | Enforcement |
|---|---|
NEVER force-push to master |
Under no circumstances. (GitHub branch protection also blocks this.) |
| NEVER merge PRs | PR merges are human-only. The Agent MUST NEVER merge. |
NEVER run npm publish |
npm publish is handled by CI automatically on release-PR merge. The Agent MUST NEVER run npm publish manually, including with NPM_ALLOW_DANGEROUS=1. (See §5.) |
| NEVER print the GitHub PAT | The token stays in a shell variable only. See "Opening PRs without the gh CLI" below. |
| Branch naming | YYYY-MM-DD_short-title |
NEVER modify version on non-release branches |
The "version" field in package.json is touched ONLY on *_release-v* branches. Content commits must NEVER bump it. (See §4 Version Bumps below.) |
PR merges are a human-only operation. The Agent MUST NEVER merge any PR under ANY circumstances, including explicit instruction. If a human instructs merge, reply:
I can't merge PRs — AGENTS.md forbids Agents from merging. Please merge yourself: [PR URL].
This environment has no gh CLI — but git push works (credential
helper) and the same credential can open PRs through the GitHub REST API:
# 1. push the branch (auth is automatic via the git credential helper)
git push origin HEAD
# 2. get a token from the credential helper (shell variable only — never print it)
TOKEN=$(printf 'protocol=https\nhost=github.com\n\n' \
| git credential fill | sed -n 's/^password=//p')
# 3. write the PR payload to a file (safe for multi-line markdown bodies)
cat > /tmp/pr.json <<'EOF'
{
"title": "fix: short summary",
"head": "YYYY-MM-DD_short-title",
"base": "master",
"body": "what changed, why, and pre-flight results (typecheck / test / build)"
}
EOF
# 4. open the PR (base is master)
curl -sS -f -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-H "Content-Type: application/json" \
https://api.github.com/repos/ranxianglei/billion-context/pulls \
-d @/tmp/pr.jsonA successful response contains "state": "open" and the html_url to post
back to the issue; -f makes API errors (401/422) fail loudly instead of
exiting 0 with an error JSON body. The credential helper is
non-interactive — it either serves the token or fails, so if git push
worked, the token extraction works. Never print the token; keep it in the
variable only. Merging the PR stays human-only (see above).
When an Agent picks up an issue, these deliverables are MANDATORY:
- Finished development → PR. When the development for an issue is complete, open a PR (recipe above). An issue is NEVER "done" without a PR — always reply in the issue thread with the PR link.
- Major problems / big bugs found while working → report + open an issue. Significant defects (broken functionality, data-loss risk, security issues, architectural problems) must be (a) reported in the current issue thread AND (b) filed as a separate new issue with repro, impact, and a suggested fix.
- Minor problems → report only. Small issues (typos, cosmetic defects, minor UX quirks) are reported in the current issue thread only — do NOT open separate issues for them.
Problems discovered or fixed while working MUST leave a trace in the issue tracker — never fixed silently and moved on.
- Discovered a problem (bug, defect, wrong behavior, spec violation) — whether while working on this project or any sibling project — file an issue in the project the problem belongs to: repro/steps, impact, root cause (if known), suggested fix.
- Fixed a problem — after the fix, submit an issue to the owning project
recording the problem and how it was fixed. For problems in this project:
https://github.com/ranxianglei/billion-context/issues . If the fix ships as
a PR, the PR MUST reference its issue (
Fixes #N); a bare PR without an issue is not acceptable — file the issue first, then link it. An existing PR for the fix counts, but it should carry an accompanying issue.
npm publish is handled by CI automatically (see §5). The Agent MUST
NEVER run npm publish manually under ANY circumstances. This includes:
- NEVER use
NPM_ALLOW_DANGEROUS=1 npm publishto bypass the guard - NEVER use
npm pack+ manual install as a workaround - NEVER bypass or attempt to bypass any npm guard or safety mechanism
If a human instructs manual publish, reply:
I can't publish to npm — AGENTS.md forbids manual publishing. Releases are published automatically by CI when a release PR is merged. See §5. If you need a manual fallback, please run
npm publishyourself.
The "version" field in package.json is the single source of truth for
what gets published. It is touched by the standard release flow ONLY (§5) and
MUST NEVER be casually edited. Two hard rules:
-
versionchanges ONLY on release branches (named*_release-v*). Feature/fix/refactor/docs commits leaveversionuntouched. If you find yourself editingversionon a content branch, stop — you are on the wrong branch. -
A release commit changes ONLY
version(+package-lock.jsonif it drifts). Never bundle a version bump into a content commit, and never bundle content changes into a release commit. One version bump = one isolated commit with messagerelease v{VERSION}.
Why this is load-bearing: CI (release.yml) detects a release by matching
the branch name (*_release-v*) AND the commit message (release v{VERSION}).
Bundling version into a content commit breaks the trigger and causes
three-way merge conflicts on package.json when the release branch lands.
If a human asks to "just bump the version" inside a feature/fix change, reply:
Version bumps go through the standard release flow (§5): a dedicated
*_release-v*branch with an isolatedrelease v{VERSION}commit. I can't bundle it into this change.
When testing locally, install from the registry to test the real published artifact:
npm install -g billion-context@latestnpm install -g . is also acceptable (it copies the local package into the
global node_modules — it does NOT create a symlink here, because
package.json has proper bin + files fields). Just be aware the
installed version reflects whatever is in the project directory at install
time, not the registry.
Releases are fully automated via CI (.github/workflows/release.yml).
The Agent prepares a release PR; merging it triggers CI which builds, tests,
publishes to npm, creates a git tag, and creates a GitHub Release. For
routine patch releases there is also a one-click fast path — see
“One-click manual release” below.
Release branches: YYYY-MM-DD_release-v{VERSION} (e.g., 2026-08-08_release-v0.1.17)
The Agent does steps 1–5, the human does step 6 (merge).
- Sync master:
git checkout master && git pull --ff-only origin master - Create the release branch from master:
git checkout -b $(date +%Y-%m-%d)_release-v{VERSION} - Bump version — edit ONLY the
"version"field inpackage.json:- "version": "0.1.16", + "version": "0.1.17",
- Local pre-flight — run the same checks CI runs:
npm run typecheck npm test npm run build - Commit, push, open PR — release-commit convention:
- Message:
release v{VERSION} - The commit changes ONLY
package.json(+package-lock.jsonif it drifts). Never bundle other changes into a release commit. - PR title:
release v{VERSION}; body lists changes since last tag.
- Message:
- Human merges the PR (Agent MUST NOT merge).
- CI publishes automatically — no manual
npm publish:- On merge,
release.ymldetects the*_release-v*branch name +release v{VERSION}commit message. - It runs
npm ci+typecheck+test+build, thennpm publish --tag latest(using theNPM_TOKENrepo secret), creates git tagv{VERSION}, and creates a GitHub Release.
- On merge,
- Verify the published version is live:
npm view billion-context version
For routine patch releases, skip the branch/PR dance: Actions →
“Release (one-click)” → Run workflow (.github/workflows/release-manual.yml).
The version input is optional — blank means auto next-patch over the npm
latest; type a full semver for minor/major/prerelease bumps. The workflow:
- Drift guard: master's
package.jsonversion must equal the npm latest, else it aborts (never release off a drifted tree). It also rejects a target version that is already published. - Bumps ONLY
package.json+package-lock.jsonand commitsrelease v{VERSION}— the same one-version-one-commit discipline as the Version Bumps section above. - Runs the full pre-flight gate (
npm ci+ typecheck + test + build). - Pushes the release commit directly to
master(GITHUB_TOKEN, fast-forward only). If branch protection blocks direct pushes, the release lands on a release branch instead and the run tries to open the release PR itself (best-effort — if the account forbids Actions-created PRs the run still finishes green with a one-click "open the release PR" link in the job summary). The fallback PR body carries a generated changelog (git logsince the last release tag); the same notes appear in the job summary as a paste-ready block for opening the PR manually. Merging that PR publishes via the standard flow; red is reserved for real failures (guard trips, gate failures, or a failed branch push). - Publishes to npm (
latest, ordevfor prerelease), tagsv{VERSION}, and creates the GitHub Release with notes generated fromgit logsince the last tag.
A successful one-click run does NOT double-trigger release.yml: its check
only matches release-branch merges / date-prefixed commits, never a plain
release v{VERSION} commit. The standard branch/PR flow above remains the
canonical path for anything non-trivial (updater changes, cross-repo bumps,
or whenever a human wants the review gate).
- Trigger: push to
masterwhere the merge commit or branch name matches*_release-v*. - Prerelease handling: if the version contains
-(e.g.0.1.17-beta.1), publishes with--tag devinstead of--tag latest. - No publish step for the Agent: the Agent never runs
npm publish. The only manual fallback (if CI is down) is a human runningnpm publish.
acp-kernel is pinned in devDependencies (exact version, no ^) and
bundled inline at build time, so dist/index.js is self-contained.
- Release
acp-kernelfirst (merge its release PR, wait for CI publish). - Verify it is live on npm:
npm view acp-kernel versionreturns the new version. - THEN bump
acp-kernelin this repo'spackage.jsonand release billion-context.
Rationale: billion-context CI runs npm ci, which installs the exact
acp-kernel version pinned in package.json. A release branch that bumps
acp-kernel to a not-yet-published version fails CI at install time.
To test that a running older version auto-updates to a newer registry version:
# 1. Install older version from registry
npm install -g billion-context@0.1.16
# 2. Merge the newer release PR (HUMAN merges) — CI publishes 0.1.17 to npm.
# 3. Start the older version
bili start --port 19195
# Within ~10s (startup check) it detects 0.1.17 and installs it, logging:
# ✔ billion-context auto-updated 0.1.16 → 0.1.17. Restart bili to finish.The auto-update code (src/update.ts) is load-bearing for every future
upgrade. If a release ships a broken auto-update, users who install it become
permanently stuck — they can never auto-update again (the broken thing is
the updater itself), and many will never notice to manually reinstall. This is
strictly worse than a normal bug: a normal bug affects one feature; a broken
updater silently bricks the upgrade path for everyone who hits it.
Therefore: any change to src/update.ts (the download / extract / install /
version-check logic) MUST be validated with a no-op release BEFORE shipping the
change. The sequence is:
- Ship a no-op release first (pure version bump, zero code changes) — this
proves the existing upgrade path is healthy end-to-end: the currently-
installed version auto-updates to the no-op release using the old code.
- Branch:
YYYY-MM-DD_release-v{VERSION}(same naming convention). - Commit:
release v{VERSION}(version bump only). - PR body MUST state it is a no-op and why (validation release).
- Branch:
- Only after the no-op release is confirmed on npm (
npm view billion-context versionreturns it) AND a real upgrade has been observed succeeding (the log showsauto-updated OLD → NEW), ship the actual change as a separate subsequent release. - If the no-op release's upgrade fails, STOP. Do not ship the updater change. Investigate the existing-path failure first — the existing code is the only known-good upgrade path, and shipping a change on top of an already-broken path compounds the problem.
Why the indirection? Because if the change-to-the-updater is itself buggy, anyone who upgrades to it is bricked. The no-op release isolates the test: it exercises the upgrade path using code we already trust, so a success confirms the plumbing (registry, tarball, file copy, restart) works, independent of the new code. Only then do we trust the new code to run on the next hop.
Concrete example (v0.1.22): the Windows auto-update fix (replacing
execFile("tar"/"cp") with the tar npm package + fs.cp) was staged in
PR#44 but NOT shipped directly. A no-op v0.1.22 (PR#46, version bump only)
was released first to confirm the running v0.1.21 could self-upgrade. Only
after that succeeded was the Windows fix shipped in a follow-up release.
npm run typecheck— no type errorsnpm test— all tests pass- Understand the module dependency graph
- Consider BOTH compression modes — any change touching the wire (message rebuild, system/developer handling, tool injection,
acp_summarystripping, preflight, nudge) must be reasoned about in BOTH plugin mode (carrier = the agent'scompresstool call; proxy suppresses injection) and proxy mode (carrier = theacp_summarymessage re-voiced asuserbysystemToUser; proxy executescompressserver-side). A change correct in one mode can break the other (#377 only manifested in proxy mode). See README "Two compression modes" and thepluginModecomment insrc/server.ts.
feat:new featurefix:bug fixrefactor:code restructuringtest:test changesdocs:documentationrelease:version bump
Distilled from a full-history review of AI auto-dev across billion-context / billion-context-pi / acp-kernel (#801): 1543 issues+PRs, 3645 comments,
devlog/, and this file's own git history. Goal: ~90% of bugfixes mergeable without rework, without drifting off direction. Full cited material:AUTO-MERGE-GUARDRAILS.md.
- Duplicate screening first. Search open AND closed issues/PRs for the same fix before implementing. If one exists, link it; do not start parallel work.
- One issue = one scope. Split extra findings into separate issues/PRs. Never bundle unrelated changes or mass whitespace/reformatting into a fix.
- Open a PR, never just push a branch. A bare branch is not a deliverable.
- Rebase to CURRENT master before claiming mergeable. Verify against the live master, not the PR's original base. After rebase, re-run typecheck + full test suite + build. A stale base is the single biggest cause of second-round rework (#425 Aug-31 base, #467 43 commits behind, #517).
- Watch hot-file contention.
src/server.ts, the preflight paths,src/persist.ts, and thesrc/agent/*extension types are touched by many concurrent PRs. If another open PR rewrites the same file/region, expect a semantic (not textual) conflict — coordinate/sequence, resolve by union of intent, then prove it by running tests (#517↔#587, #571↔#558). - One linear commit, clean diff. No merge commits or rebases that explode the diff and bury the real change; no incidental whitespace re-alignment. Every line must relate to the PR's purpose (#571 "diff-爆炸", #467).
- Done = evidence, not "should work". Double-review the code, then actually run the changed behavior and observe it matches expectation. For fixes that change context/wire behavior, prefer a real end-to-end A/B against the issue repro over unit tests alone (#254).
- Tests must be deterministic. No assertions that depend on environmental
luck (e.g. assuming a port range is free — Windows' ephemeral range
49152–65535 collides with fixed picks; request a port via
listen(0)instead, #360).
- Root cause, not symptom (疏, not 堵 — first principle for every fix). A fix removes the mechanism that PRODUCES the defect; it never merely hides the defect's visible trace at whatever layer is cheapest to intercept. Before writing any fix, name the producer of the offending bytes/behavior and fix it at the source or at the decision point — never by rewriting or filtering payload in transit to make the symptom invisible. Litmus test: "if the same root cause surfaced in a different shape tomorrow, would this fix still hold?" If not, it is a mask, and a mask is a latent bug. Canonical failure: #933 (model-echoed render tags leaking into a TUI) was "fixed" by stripping tool-call arguments mid-stream; the mask itself became #1039's silent data corruption once users legitimately wrote tag-shaped payloads through write/edit/bash. When the true root cause is out of scope (another repo, a product decision), fix the in-scope part, leave a documented boundary note pointing at the real home of the fix, and do NOT compensate in the wrong layer (#1039's echo-noise belongs on the injection side — host renderTags policy — not on the wire). See also "Symptom ≠ mechanism" below (attribution discipline) and the tool-args-verbatim invariant in the Wire fidelity bullet.
- Never silently clobber or drop user config. Any read-modify-write on user config needs a parse-state guard; reject malformed input loudly (HTTP 400/409) instead of merging into defaults or dropping fields. Whitelists must be complete (#155: a missing key silently erased custom compress prompts).
- Sane defaults & fallbacks. Fallback values must be reasonable (never a too-small value that causes thrashing); a static/fallback source must always lose to a fresher authoritative source when both are cheaply available (#282: window fallback 200K/min 100K, not 64K; the bundled registry snapshot must not outrank the live registry).
- Prefer native stable identifiers. Use a client's native stable session id when available (it survives credential/model/provider switches); report clients that expose none. Do not build identity from derived hashes that drift on switch (#280).
- Wire fidelity (host-side duty). Never alter upstream protocol shape beyond
intended injection: preserve tool_call ids/ordering, SSE structure, and
upstream invariants (e.g.
compaction_triggermust remain the last input item, #283/#209). Reason in BOTH compression modes (§6).- Tool-call arguments are user intent (#1039). Any payload a host will
EXECUTE or PERSIST — tool-call
argumentsin every wire shape (OpenAItool_calls[].function.arguments, Anthropicinput_json_delta.partial_json/tool_use.input, Responsesfunction_call_arguments.*), fragment or whole — is forwarded byte-exact. NEVER filter, strip, or "clean" it, not even to remove model-echoed render tags: a shape-based filter cannot distinguish an echo from a literal the user genuinely wants written (bash command strings, write/edit file contents), so any such "fix" silently corrupts executed/persisted data. Echoed tags surfacing in a host TUI is cosmetic noise — the fix for that belongs on the injection side (host renderTags policy, #933), never in the argument path. Tag-echo stripping applies to model PROSE only (content/reasoning/thinking/summary text fields); see the invariant block atopsrc/loop/tag-echo-filter.ts. - Kernel-owned split: the FORMAT CONTRACT of the kernel-emitted ACP
artifacts (the compression tags, block refs,
acp_summarystructure — see Key Design Decision #2) and the id-never-reused guarantee belong to acp-kernel, not this repo (see §2 "Kernel Contract"). This repo only consumes them faithfully. Codifying the kernel-side spec is a separate acp-kernel change — deferred; cross-repo work stays manual for now.
- Tool-call arguments are user intent (#1039). Any payload a host will
EXECUTE or PERSIST — tool-call
- Symptom ≠ mechanism. Before attributing a bug to bili's mechanism, verify against upstream logs — repeated-compression logs may be an upstream rate-limit retry illusion, not over-compression (#282).
- Honest output. Never emit misleading messages for degenerate states (#155: export claimed "original conversation" for a 0-block session).
- Logs. Mask secret values in all logs; separate trace/debug/info; keep debug-on by default during bug-convergence phases (#247).
- Docs. Keep zh/en in sync; place content where the actual reader will see it; mirror env-var references into CONFIGURATION.md, not just one README (#571).
A bugfix may auto-merge only if ALL hold:
- Single-module scoped fix; no architectural change.
- A regression test reproduces the original bug and now passes.
- Green on the rebased head (typecheck + full test suite + build).
- No change to: config schema, persistence format/version, wire protocol / message shape, or cross-repo dependencies (acp-kernel).
- Pure
fix:— no new capability surface (not feat/refactor). - Clean diff: no unrelated changes, no mass whitespace/reformat, no generated or lock-file churn.
- References its issue via
Fixes #N. - Does NOT touch load-bearing infra:
src/update.ts, release workflow, CI publish, the acp-kernel pin, message-ref/id logic, or security (MITM/CA/ credentials).
Must stay human (any hit): wire/message-shape changes (both modes affected),
config schema or persistence version, cross-repo dependencies, src/update.ts
(needs a no-op release first), identity/session-binding logic, feat/refactor/
architecture, security-related, or any fallback/default-value change (a product
decision).
Scope note: auto-merge applies to THIS repo only. Cross-repo changes (acp-kernel bumps, anything spanning repos) remain manual/human for now.
Of 326 analyzed merged PRs, 26 (~7%) needed a second+ human review round. Two drivers dominate, and they are exactly where auto-merge is unsafe:
- Stale-base / concurrent-file churn — long-lived branches drift from fast-moving master and collide with other PRs on hot files. Gate signals: branch freshness, and whether a touched file is being rewritten by another open PR.
- Incomplete first pass — the initial fix addresses the reported symptom but misses an adjacent path/edge case, leaves promised work unfinished, or needs its approach reconsidered. Gate signal: does the fix cover ALL paths of the bug, not just the repro?
These map directly onto §7.2–7.3; a reviewer walks those bullets in order.