Skip to content

Latest commit

 

History

History
747 lines (546 loc) · 57.4 KB

File metadata and controls

747 lines (546 loc) · 57.4 KB

billion-context Configuration Reference

English | 中文

billion-context is an HTTP proxy that injects ACP (Active Context Pruning) context compression into LLM API streams. Every option below lives in a single JSON config file (or an equivalent environment variable / CLI flag).


Config File Locations

Scope Path Notes
Config file (Linux) ~/.config/billion-context/billion-context.json XDG Base Directory — the canonical, user-editable config
Config file (override) value of XDG_CONFIG_HOME relocates the whole config dir
Config file (explicit) value of BILI_CONFIG_FILE points directly at any JSON file
CLI flag --config <FILE> same as BILI_CONFIG_FILE; highest precedence for the file path
Session data ~/.local/share/billion-context/sessions/ persisted compression state, grows over time

On first run, billion-context seeds an empty template ({ "providers": {} }) at the config path so you have something to edit. It never overwrites an existing file.

The config file is a pure override layer — every field is optional. Anything unset falls through to the built-in default.


Quick Start

// ~/.config/billion-context/billion-context.json
{
  // Server
  "port": 8787,
  "host": "127.0.0.1",

  // Route two providers
  "providers": {
    "https://api.anthropic.com": {
      "models": {
        "claude-sonnet-4-5": { "context": 200000, "output": 8192 }
      }
    },
    "https://generativelanguage.googleapis.com": {
      "models": {
        "gemini-2.5-pro": { "context": 1000000 }
      }
    }
  },

  // Global compression tuning (applies to every request)
  "compress": {
    "maxContextLimit": "75%",
    "emergencyThresholdPercent": "95%"
  }
}

Parameter Reference

Status legend: ACTIVE = currently used | DEPRECATED = accepted but no effect | EXPERIMENTAL = may change


Server Settings

Top-level keys that control how the proxy listens and behaves globally.

port

  • Type: number
  • Default: 8787
  • Status: ACTIVE
  • Description: TCP port the proxy listens on. Must be an integer between 1 and 65535. Overridden by the ACP_PORT (or PORT) environment variable, or the --port CLI flag. An invalid value aborts startup.

host

  • Type: string
  • Default: 127.0.0.1
  • Status: ACTIVE
  • Description: Network interface the proxy binds to. 127.0.0.1 (default) listens only on localhost — safe for a local sidecar. Use :: for IPv4 + IPv6 dual-stack. Use 0.0.0.0 (or a LAN IP) to expose the proxy to other machines — typically inside a container or on a trusted LAN: remote agents then point their model baseURL at http://<this-host>:<port>/bili/…, and MITM-mode CONNECT accepts remote clients for whitelisted model hosts only (blind tunnels stay loopback-only, and /__bili/ management endpoints remain loopback-only). There is no authentication — ensure the surrounding network is trusted. Overridden by ACP_HOST / --host.

sessionHeader

  • Type: string
  • Default: x-acp-session
  • Status: ACTIVE
  • Description: Name of the HTTP request header clients may send to identify a conversation. Requests carrying the same value share compression state across calls. Overridden by ACP_SESSION_HEADER.

log

  • Type: boolean
  • Default: true
  • Status: ACTIVE
  • Description: Enable per-request logging. Set false (or ACP_LOG=0) to silence the standard request log.

debug

  • Type: boolean
  • Default: false
  • Status: ACTIVE
  • Description: Verbose logging — equivalent to setting ACP_DEBUG=1. Useful for diagnosing routing or compression behaviour.

passthrough

  • Type: boolean
  • Default: false
  • Status: ACTIVE
  • Description: Forward every request to the upstream without compression, tool injection, or nudging. Equivalent to ACP_PASSTHROUGH=1. Handy for A/B comparison against the uncompressed baseline.

compat

  • Type: { roles?: Record<string, string> }
  • Default: {} (disabled)
  • Status: ACTIVE
  • Description: Global wire-compat role map. roles maps message roles to the role name your upstream accepts, e.g. {"compat":{"roles":{"developer":"system"}}} rewrites developersystem on the final forwarded body for upstreams that reject the developer role (#552, newer codex clients). Applies to openai chat-completions and responses requests; exact-match roles only, everything else in the body is untouched; re-sent compress-retry bodies carry the same rewrite. Per-provider compat.roles entries (see Providers) win per key. Default {} forwards bodies byte-for-byte unchanged.
  • Learn-on-failure: with no compat configured, an upstream 400 Invalid role: … is auto-fixed — bili rewrites the offending role to system, retries once, and remembers the mapping session-scoped (in-memory on the session; never written to config). Later requests in that session skip the 400 round-trip. The info log emitted when the fix fires carries the permanent per-provider snippet.

proxy

  • Type: string
  • Default: (none — no upstream proxy)
  • Status: ACTIVE
  • Description: Upstream HTTP proxy (http://host:port) used for the proxy's own outbound connections to model providers. SOCKS5 is not supported. A per-URL proxy set inside a providers entry overrides this for that provider. An empty string means "explicitly direct" — it disables any environment/system proxy fallback for all providers.

Providers

The providers block maps upstream URLs to per-provider configuration. Each key is a URL prefix; each value can declare model context windows, a per-provider proxy, a compression protocol, compression overrides, and a per-route passthrough.

{
  "providers": {
    "https://api.anthropic.com": {
      "models": {
        "claude-sonnet-4-5": { "context": 200000, "output": 8192 }
      },
      "proxy": "http://10.0.0.1:7890",
      "compressProtocol": "tools",
      "compress": { "maxContextLimit": "70%" }
    }
  }
}

URL key matching

Keys are matched against the request's upstream URL by longest-prefix wins. A key matches if the request URL equals the key, or starts with key + "/". This makes matching boundary-safe: a key https://api.example.com matches https://api.example.com/v1/chat but does not match https://api.example.com.evil (an attacker-controlled lookalike host).

A shallow key (https://open.bigmodel.cn) matches every path on that host. A deep key (https://open.bigmodel.cn/api/anthropic) matches only that endpoint. When two keys both match, the longest (most specific) one wins. Trailing slashes on keys are stripped automatically.

models

  • Type: Record<string, { context?: number; output?: number; compress?: CompressSettings }>

  • Default: (none)

  • Status: ACTIVE

  • Description: Maps a model name to its context-window declaration. The LLM /models endpoint does not return context windows (verified across OpenAI, Anthropic, zhipu, comfly), so the proxy cannot discover them at runtime. context is the model's context window in tokens; output is the max output size.

    Resolution order (first match wins): (1) per-request sources — the client's anthropic-beta larger-context negotiation, a cooperative plugin's report, and the launcher's per-model windows; (2) this per-model context declaration; (3) the warm models.dev registry cache, when the model is listed (relay/private hosts match the bare model name against the registry's provider-prefixed entries); (4) the built-in context table. So this per-model context declaration outranks the registry — set it to the window your relay/private deployment actually serves, and it wins even when models.dev lists a different (usually larger) window for the model. compress.modelContextLimit remains the highest-priority source (always wins) when you want to pin the window across every route. Each model entry may also carry a per-model compress block (see Compression Tuning).

proxy

  • Type: string
  • Default: (inherits top-level proxy)
  • Status: ACTIVE
  • Description: Per-provider upstream HTTP proxy (http://host:port). Overrides the top-level proxy for this provider only. An empty string means "explicitly direct" — override the global proxy with no proxy for this one provider.

compressProtocol

  • Type: "tools" | "marker"
  • Default: "tools"
  • Status: ACTIVE
  • Description: How compression tools are injected into the request. "tools" (default) injects them as native function-call tools. "marker" uses a text-trigger protocol instead — use this for upstreams that cannot coexist with a declared tools field.

compress

  • Type: CompressSettings
  • Default: (inherits global compress)
  • Status: ACTIVE
  • Description: Per-provider compression overrides. This is level 2 of 3 in the merge hierarchy — see Compression Tuning.

compat

  • Type: { roles?: Record<string, string> }
  • Default: {} (disabled)
  • Status: ACTIVE
  • Description: Per-provider wire-compat overrides. roles maps message roles to the role name this upstream accepts, e.g. {"developer": "system"} for upstreams that reject the developer role newer codex clients send (#552). Applied to the final forwarded openai/responses body — client-sent roles and bili's own injected prompt alike — and to every body the compress-retry loops re-send. Wins per key over the global compat block (see Server Settings). Default {} forwards byte-for-byte unchanged.

passthrough

  • Type: boolean

  • Default: (none — compression active)

  • Status: ACTIVE

  • Description: Per-route override of the global passthrough setting. When true, every request matching this route is forwarded byte-for-byte: no kernel round-trip (no message re-serialization, no ACP render tags, no prompt_cache_key removal), the response is piped through untouched, and no session state is created for that route. Use this for upstreams whose anti-fraud fingerprinting rejects bili's rewritten bodies — e.g. ZCode's 405 / 3012 ("request has been blocked due to unusual activity") on the kernel-rebuilt messages body (#661). A mitm:// key targets only the MITM (login-client) traffic of that host, while a plain https:// key covers both MITM and /bili/ (API-key) traffic:

    {
      "providers": {
        "mitm://zcode.z.ai": { "passthrough": true }
      }
    }

Compression Tuning

Compression behaviour is controlled by the compress block, which can appear at three levels. They merge per-field, deepest wins: a field set at a deeper level overrides the same field higher up, but an unset field at a deeper level never clears a value set higher up. In other words, the child covers the parent field-by-field — it never replaces the whole object.

The three levels, from broadest to most specific:

  1. Global — a top-level "compress": { … } key. Applies to every request. This is the only level where the injectTool / injectNudge toggles are honoured.
  2. Per-provider — a "compress": { … } block inside a providers[url] entry.
  3. Per-model — a "compress": { … } block inside a providers[url].models[model] entry.

For each request, the proxy resolves the settings by longest-URL-prefix match (to find the provider) and the request's model name (to find the model entry), then merges global → provider → model.

CompressSettings fields

modelContextLimit

  • Type: number | string
  • Default: (the model's native window)
  • Status: ACTIVE
  • Description: The context window size, in tokens. This is the denominator the engine uses for its usage ratio (usage = tokens / modelContextLimit) — it is not a truncation cap. Accepts an absolute number (200000) or a percent string ("80%" = 80% of the model's native window, resolved from the built-in table or models.dev registry). When omitted at every level, the native window is used. This is the highest-priority source for the model limit; it overrides the built-in table, the legacy per-model context field, and the top-level modelContextLimit.

maxContextLimit

  • Type: number | string
  • Default: "75%"
  • Status: ACTIVE
  • Description: Context-usage threshold that triggers forced compression nudges. Once usage crosses this ratio, the engine fires a nudge that bypasses the growth-gate and cadence checks. Accepts a ratio (0.75) or a percent string ("75%"). Lower values compress earlier. Maps to the kernel field nudge.maxContextLimitPct.

emergencyThresholdPercent

  • Type: number | string
  • Default: "95%"
  • Status: ACTIVE
  • Description: Context-usage threshold that triggers emergency truncation of large tool outputs. Accepts a ratio or a percent string. Must be greater than or equal to maxContextLimit. Maps to the kernel fields nudge.emergencyThresholdPct and truncate.threshold.

nudgeGrowthTokens

  • Type: number
  • Default: 50000
  • Status: ACTIVE
  • Description: Token-growth step for soft compression nudges. A nudge fires roughly every time this many tokens become compressible. Lower values produce more frequent nudges. Maps to the kernel fields nudge.growthFloor and nudge.growthCap (it flattens the engine's adaptive band to this fixed step).

preserveRecentMessages

  • Type: number
  • Default: (kernel default, typically 5)
  • Status: ACTIVE
  • Description: Number of most-recent messages that are never offered for compression. Protects the active working set so the model retains the latest turns verbatim. Maps to the kernel field preserveRecentMessages.

preserveRecentTokens

  • Type: number
  • Default: (kernel default, typically 5000)
  • Status: ACTIVE
  • Description: Token budget reserved for recent-message protection. Maps to the kernel field preserveRecentTokens.

minCompressRangeChars

  • Type: number
  • Default: (kernel default, typically 5000)
  • Status: ACTIVE
  • Description: Minimum range size, in characters (not tokens), for a message range to be eligible for compression; smaller ranges are skipped. English/code averages ~4 chars per token, CJK ~1-2, so the same number reads ~4× more permissive for English text than a token-based mental model. Maps to the kernel field compress.minCompressRange.

minCompressRange

  • Type: number
  • Status: DEPRECATED (alias of minCompressRangeChars, kept for backward compatibility)
  • Description: Legacy name for minCompressRangeChars — same kernel mapping (compress.minCompressRange), same unit (characters). When both keys are set at the same level the canonical name wins; across levels the deeper level wins regardless of which name it uses.

tiers

  • Type: boolean
  • Default: true
  • Status: ACTIVE
  • Description: Enable multi-tier compression — tier-2 distillation of old summaries and tier-3 condensation. Set false to run in tier-1-only mode (every summary is a flat tier-1 summary). Maps to the kernel field tiers.enabled.

prompts

  • Type: object ({ compressPhilosophy?, howToCompressRules?, tier2DistillRules?, tier3CondenseRules? }, all strings)
  • Default: (kernel defaults — see acp-kernel defaultPrompts)
  • Status: ACTIVE
  • Description: Override the compression prompt text injected into the system prompt and nudge messages. Every field is load-bearing: the kernel rules were tuned over months of production use, and overriding them can degrade summary quality (lost paths / signatures / decisions → broken retrieval). Overrides only take effect when acknowledgePromptsRisk: true is set at the same (winning) level; otherwise they are ignored and a one-time warning is logged. Non-string fields are silently dropped (a malformed partial never clobbers a good default). Useful mainly for non-English or small-model tuning — see issue #156.

acknowledgePromptsRisk

  • Type: boolean
  • Default: false
  • Status: ACTIVE
  • Description: Must be true for prompts overrides to take effect. Setting it acknowledges the summary-quality risk documented above.

absorb

  • Type: object ({ enabled?, minToolTokens?, contextThresholdPct?, excludeTools?, toolName? })
  • Default: (disabled — the feature is off unless you set enabled: true)
  • Status: ACTIVE
  • Description: Opt-in instant tool-result compression (issue #605, via the acp-kernel absorb API). When enabled, large tool results get a forced [ACP absorb] instruction at result time; the model distills the result into a compact summary via the absorb tool, and the original tool-call/tool-result pair is hidden from the wire from the next turn on — keeping mid-session pressure lower between fold rounds. Sub-fields (merged deepest-wins like every other CompressSettings field):
    • enabled: boolean — master switch; anything other than true keeps the feature fully off (no tool, no prompt, no markers).
    • minToolTokens: number — only results at or above this many tokens are prompted (kernel default 1000).
    • contextThresholdPct: number|percent-string — only prompt once usage reaches this fraction of modelContextLimit (0 = size gate alone; "75%" is accepted).
    • excludeTools: string[] — tool-name patterns never absorbed. Known limitation: a no-op on tool results until ranxianglei/acp-kernel#213 ships (wire projections don't carry toolName on results, so the kernel's name guard can't fire).
    • toolName: string — rename the injected tool (default "absorb"); the schema, system-prompt section and per-session adjudication all follow the name. Injection follows the wire's native-tool surface: proxy mode injects the tool + a static system-prompt section on the anthropic/openai/responses native-tools wires, plugin mode advertises it in the plugin manifest (the MCP shell picks it up for free). Responses marker/text-protocol routes are not supported (no native tool surface — the REQUIRED absorb instruction would be unsatisfiable), and title-generation requests (max_tokens ≤ 200) skip injection like the compress prompt does. Absorbed pairs stay hidden across restarts (persisted in the session state).

reasoning

  • Type: object ({ drop?, threshold? })
  • Default: drop: true, threshold: 2048 — on
  • Status: ACTIVE
  • Description: Compress-reasoning hygiene (issue #651, the proxy-side twin of billion-context-pi #339/#348 / opencode-acp #377). Models that keep their reasoning/thinking traces on the wire accumulate a permanent uncompressible floor: the anchor of a fold is a compress call, and any reasoning messages sitting before that call survive every fold as part of the protected prefix — they can never be re-summarized, only stripped. In the storm sessions this floor reached ~50% of the visible context. When on, the proxy removes the reasoning run that immediately precedes a closed compress call — closed on round evidence: the call's tool result (contentType: "tool-result", matching toolCallId) has arrived at a later index and at least one message exists after it. No user message is required, so long agentic sessions close rounds too [#348 twin]. Safety gates: the in-flight round (result missing, or result still the last message) is never touched; runs of ordinary tool calls (read, bash, …) keep their reasoning; a run is judged by its summed length so a 2×1200-char run still trips a 2048 gate; non-contiguous reasoning (text between the fragments) is left alone. Sub-fields (merged deepest-wins like every other CompressSettings field):
    • drop: boolean — kill-switch; false restores the old wire verbatim. Required per-provider for thinking models that mandate reasoning round-trip while the request carries tools — DeepSeek, GLM thinking and Qwen-QwQ return HTTP 400 when a prior reasoning_content is not echoed back:
      "providers": { "https://api.deepseek.com": { "compress": { "reasoning": { "drop": false } } } }
    • threshold: number — character gate; runs strictly greater than this are dropped (0 = drop any non-empty run). Invalid values fall back to the default instead of throwing.

stripImages

  • Type: boolean
  • Default: false
  • Status: ACTIVE
  • Description: Opt-in removal of historical image payloads. When true, every message except the most recent stripImagesKeepRecent has its image parts dropped before the wire rebuild; an image-only message collapses to a single [image] text placeholder (mixed text+image messages keep their text). Recent-N images are forwarded verbatim, and a freshly-sent image always falls inside that window on the turn it arrives. Off by default — while off, the #488 image-token floor and its overflow 502 stay the opt-in signal for image-heavy payloads. Applies to both compression modes (in plugin mode the agent's own history is untouched; only the upstream-bound wire is slimmed). See issue #617.

stripImagesKeepRecent

  • Type: number
  • Default: 5
  • Status: ACTIVE
  • Description: With stripImages: true, how many trailing messages keep their images verbatim. Ignored unless stripImages is enabled.

Injection toggles (global only)

These two toggles are honoured only at the global level. Setting them inside a per-provider or per-model compress block has no effect.

injectTool

  • Type: boolean
  • Default: true
  • Status: ACTIVE
  • Description: Inject the compress / decompress / search_context tools and the compression system prompt into each request. Set false (or ACP_COMPRESS_TOOL=0) to disable tool injection entirely.

injectNudge

  • Type: boolean
  • Default: true
  • Status: ACTIVE
  • Description: Inject automatic compression-nudge messages when usage thresholds are crossed. Set false (or ACP_COMPRESS_NUDGE=0) to disable nudge injection. Disabling both injectTool and injectNudge is functionally similar to passthrough, except the proxy still tracks token usage.

Three-level merge example

This example shows global defaults, a per-provider override, and a per-model override all stacking per-field:

{
  // Level 1 — global: applies to every request
  "compress": {
    "maxContextLimit": "75%",
    "emergencyThresholdPercent": "95%",
    "nudgeGrowthTokens": 50000,
    "tiers": true,
    "injectTool": true,
    "injectNudge": true
  },

  "providers": {
    "https://api.anthropic.com": {
      // Level 2 — per-provider: overrides global fields for this provider
      "compress": {
        "maxContextLimit": "70%",          // compress a bit earlier here
        "preserveRecentMessages": 8        // keep more recent turns
      },
      "models": {
        "claude-sonnet-4-5": {
          "context": 200000,
          // Level 3 — per-model: the deepest, highest priority
          "compress": {
            "modelContextLimit": 180000,   // treat window as 180k (leaves headroom)
            "emergencyThresholdPercent": "90%"
          }
        }
      }
    }
  }
}

For a request to https://api.anthropic.com/v1/messages with model claude-sonnet-4-5, the resolved settings are:

Field Resolved from Value
maxContextLimit provider (level 2) "70%"
emergencyThresholdPercent model (level 3) "90%"
nudgeGrowthTokens global (level 1) 50000
preserveRecentMessages provider (level 2) 8
modelContextLimit model (level 3) 180000
tiers global (level 1) true

Environment Variables

Environment variables take precedence over the config file. They are useful for environment-specific overrides (CI, containers) without editing the file.

Variable Effect
ACP_DEBUG Set to 1 for verbose logging (same as "debug": true).
ACP_PASSTHROUGH Set to 1 to forward without compression (same as "passthrough": true).
ACP_COMPRESS_TOOL Set to 0 to disable tool injection (same as "compress.injectTool": false).
ACP_COMPRESS_NUDGE Set to 0 to disable nudge injection (same as "compress.injectNudge": false).
ACP_MODEL_CONTEXT_LIMIT Override the context limit globally (absolute token count).
BILI_IMAGE_TOKEN_CAP Cap the per-image token estimate used by the preflight size gate and output clamp (#488/#496). By default an inline data: image counts as base64 length / 4 tokens with no cap — correct for byte-billing relays, but a large over-estimate for pixel-tile upstreams (official Anthropic/OpenAI), which bill each image at roughly 1.1K–1.6K tokens regardless of byte size. Set this to your upstream's per-image tile cost so the gate reflects real billing; unset = no cap (current default).
BILI_PREFLIGHT_HOLD_MS Grace period (ms) before a long preflight compression starts holding the client with keep-alive bytes (default 30000; see #568 / README "Preflight hold").
BILI_CONFIG_FILE Override the config file path (point at any JSON file).
ACP_PORT / PORT Override the listen port.
ACP_HOST Override the listen host.
ACP_UPSTREAM Override the default upstream base URL.
ACP_LOG Set to 0 to disable request logging.
ACP_AUTO_UPDATE Set to 0 to disable auto-update checks.
ACP_UPDATE_TAG Dist-tag channel the auto-updater follows (default latest, e.g. dev). File-config key: updateTag. A pr-N preview tag is only followed when explicitly configured.
BILI_HOST_USAGE_CREDIT #408 host-usage backfill mode (file-config key: hostUsageCredit). auto (default) = the uncompressed-baseline backfill is armed for plain proxy clients (the bili-launched pi/omp extensions are exempted — their host-side compaction is cancelled, so the baseline drives nothing there). off = never backfill — the usage reported to the host is the actually-forwarded (folded) request, matching [acp-usage] input=. Use off for plain anthropic proxy clients (e.g. ZCode) whose UI would otherwise show the cumulative, drifting baseline as inflated context (#648).
ACP_PROVIDERS Path to an external providers.json (legacy / shared file).
BILI_REPLAY_RETRY_BASE_MS Base backoff delay (ms) for acp-loop replay retries after a transient upstream rejection (default 1500; set 0 to disable the delay). See #189.
BILI_REPLAY_RETRY_MAX Total attempts for acp-loop replay retries (default 3; set 1 to disable retries entirely — legacy fail-fast behavior). See #189.
BILI_UPSTREAM_TIMEOUT_MS Idle budget (ms) for upstream requests: time-to-first-byte and time between body chunks (default 720000 = 12 min). A healthy stream that keeps producing chunks is never cut mid-flight; a silent one is. The same value drives the underlying HTTP client's transport timeouts, so this single knob bounds long local-model prefills end-to-end (#551).
ACP_SESSION_HEADER Conversation-id header name (default x-acp-session).
ACP_REASONING_KEEP Responses API only: set none to drop all reasoning items. Default routes reasoning through the compression pipeline so it is hidden automatically once its turn is summarized (prevents the unbounded accumulation that broke Codex's prompt-cache prefix).
ACP_LOG_FILE Log file path (default XDG state path; off disables the file, keeps stderr). Auto-rotates at 10 MB.
ACP_DUMP_SSE Directory to dump raw SSE frames for debugging.
BILI_UPSTREAM_PROXY Upstream proxy for the proxy's own outbound connections — highest priority, above per-URL/per-provider config. See the README Upstream proxy section.
BILI_PERSIST Set 0 to disable session persistence (in-memory only, lost on restart).
BILI_PERSIST_DEBOUNCE_MS Debounce window for persistence writes to disk, in ms (default 500).
BILI_PERSIST_TAIL_TOKENS Token budget for the persisted conversation snapshot (#401). The disk record stores the folded view (block summaries in place of compressed ranges) truncated to the newest messages within this budget — never the raw full history. Default 16384; 0 disables message persistence entirely (block summaries and compressed originals still persist; bili export falls back to block-only rendering). Live in-memory sessions are unaffected — bili export of a live session is always complete.
BILI_PERSIST_EPERM_ALERT_THRESHOLD N consecutive persist write failures (EPERM/EBUSY/EACCES) on one session before the one-time "add this dir to antivirus exclusions" alert fires (default 5). Windows only. See Windows: exclude the sessions dir.
BILI_PERSIST_EPERM_ALERT_REPEAT_MS Re-alert window for the persist EPERM alert, in ms. 0 (default) = alert once then stay silent; >0 = re-alert at most every that many ms while the failures continue.
BILI_TUNNEL_ALLOWED_HOSTS /bili/<absolute-url> tunnel admission for remote clients (#409): comma-separated host or host:port entries that unlock loopback/private destinations (e.g. a LAN relay or the machine's own sglang) for non-loopback clients. The proxy itself and link-local/metadata addresses are always denied; local (loopback) clients always pass.
BILI_MAX_SESSIONS Max sessions held in memory (default 256; LRU eviction — disk is the source of truth).
BILI_SESSIONS_DIR Directory for persisted session state (default XDG data dir).
BILLION_CONTEXT_PROXY Exported by the launcher; client-side bili plugins/extensions detect it and self-disable their own compression (no double compression).
BILLION_CONTEXT_PLUGIN Set 0 to disable plugin mode entirely (wire-level tool injection resumes).
BILI_LAUNCHER_MODEL_WINDOWS Internal: the launcher hands the client's own per-model context windows (pi models.json, omp models.yml, opencode models.<id>.limit, codex model_context_window) to the spawned proxy as JSON, so the nudge denominator matches the real window for self-hosted models. Only the launcher sets it — no user configuration.
BILI_LAUNCHER_PLUGIN Set 0 to disable the launcher's bili MCP server injection for claude/codex (pure wire mode); 1 forces plugin mode. Default: injected — except codex with a local/private upstream (sglang/vllm/ollama cannot parse codex's namespace tool type, so bili auto-falls back to wire tools there). See Launcher Reference.
BILI_LAUNCHER_DIRECT Set 1 for direct-URL routing in the launcher (drop MITM/CA trust). See Launcher Reference.
BILI_CLAUDE_UPSTREAM claude direct mode: your relay endpoint, when ANTHROPIC_BASE_URL already points at a relay the launcher would otherwise bypass.
BILI_CODEX_COMPACT Codex native-compaction handling. Default intercept: bili intercepts codex's compaction requests and forges a local handoff to the ACP state when the safety gate passes (transform ok + steady-state usage < 90% of the window + at least one active compressed block) — trigger form forges a 2-frame SSE, endpoint form forges {output} — and never contacts upstream. Forged ACP summaries are re-injected as a history-borne handoff message (developer-message fallback) so compressed content stays visible after codex truncates its history. Set pass to opt out and forward codex's compaction requests upstream (native compaction backstops). On any gate failure the request passes through untouched.

CLI Reference

Full command surface (bili --help prints an abridged version). Precedence everywhere: CLI flag > env var > config file > built-in default.

Command What it does
bili [start] [options] Start the proxy (reads the XDG config file by default)
bili pi [opts --] [args] Start a proxy + launch pi against it
bili pi-test [opts --] [args] Like bili pi, but adds --no-extensions (clean-room test — the proxy owns compression)
bili codex [opts --] [args] Proxy + codex
bili claude [opts --] [args] Proxy + claude (Claude Code CLI)
bili omp [opts --] [args] Proxy + omp (pi-based)
bili opencode [opts --] [args] Proxy + opencode
bili hermes [opts --] [args] Proxy + hermes-agent (/bili/ rewrite)
bili dsh [opts --] [args] Proxy + deepseek-harness (non-loopback upstreams via proxy envs, loopback via /bili/ rewrite — #535; args like --profile web "task" pass through)
bili test pi Non-polluting end-to-end smoke test of the pi path
bili export [session] [--full] [--output FILE] List persisted sessions / export one as a Markdown handoff — see Sessions & Migration
bili update Check for & install a newer version now (bypasses the 3-minute throttle)
bili plugin install <agent> Install the native-tool plugin / MCP bridge into a host — see Plugin Mode
bili plugin remove <agent> Remove it again
bili plugin list Show install status for every host
bili mcp Run the bili MCP server standalone on stdio
bili plugin-register <id> [--origin URL] [--agent name] Pre-bind a conversation id to plugin mode (advanced)
bili --version / bili --help Print version / help

Anything after -- in a launcher command is passed through to the client verbatim (bili pi -- print "hi").

Options

Flag Effect
--port <N> Listen port (default 8787)
--host <ADDR> Listen host (default 127.0.0.1)
--config <FILE> Path to config JSON (default: XDG location)
--debug Verbose logging
--passthrough Forward without compression
--no-passthrough Force compression on (overrides config)
--no-auto-update Disable background self-update for this run
--mitm-domain <domain> Extra MITM whitelist entry (repeatable; launcher only)

Client Integration

Two ways to point a client at the proxy without the launcher: the /bili/ prefix (API-key clients) and MITM transparent mode (login clients with hardcoded endpoints).

/bili/ prefix (API-key clients)

Clients you configure with an API key (not a login) let you change the upstream URL. Prepend the proxy origin + /bili/ to it — that's the only change. The API key stays in the client config and is passed through untouched.

OpenCode — edit ~/.config/opencode/opencode.json, change the provider's baseURL:

// before:
"baseURL": "https://open.bigmodel.cn/api/coding/paas/v4"
// after (prepend the proxy origin + /bili/):
"baseURL": "http://localhost:8787/bili/https://open.bigmodel.cn/api/coding/paas/v4"

Codex (API key) — edit ~/.codex/config.toml, change the provider's base_url:

# before:
base_url = "https://api.openai.com/v1"
# after:
base_url = "http://localhost:8787/bili/https://api.openai.com/v1"

Codex (ChatGPT login) — set the top-level openai_base_url field (keeps model_provider = "openai" and OAuth login intact):

# ~/.codex/config.toml (top-level field, not a section)
model_provider = "openai"
openai_base_url = "http://localhost:8787/bili/https://chatgpt.com/backend-api/codex"

Run codex login as usual; the OAuth token travels in the Authorization header, which the proxy forwards untouched.

Pi — edit ~/.pi/agent/models.json, change the provider's baseUrl:

// before:
"baseUrl": "https://api.anthropic.com"
// after:
"baseUrl": "http://localhost:8787/bili/https://api.anthropic.com"

Claude Code — set the ANTHROPIC_BASE_URL env var to the /bili/ URL. (claude's undici fetch ignores HTTPS_PROXY, so the /bili/ URL form is the only manual option — cert MITM cannot intercept it.)

export ANTHROPIC_BASE_URL="http://localhost:8787/bili/https://api.anthropic.com"

Auto-compact alignment (manual mode only). The bili claude launcher automatically sets CLAUDE_CODE_AUTO_COMPACT_WINDOW to bili's effective window for your model, so claude's own auto-compact threshold lines up with bili's compression budget. In manual /bili/ mode you must do this yourself — otherwise claude may run its own local auto-compact (a "summarize the conversation" turn) on a threshold that doesn't match bili's window. That is usually harmless (same session-id, so bili re-derives state from the truncation) but noisier than needed. Set it to bili's effective window for your model:

export CLAUDE_CODE_AUTO_COMPACT_WINDOW=<bili effective window in tokens>

claude clamps this value down to the window it perceives for the model (never up), so over-setting is safe. You can also set it persistently via claude's settings (autoCompactWindow).

Other API-key clients (Cursor / Aider / Continue …) — wherever the upstream URL is configured, prepend http://localhost:8787/bili/. Nothing else changes.

The /bili/ prefix doubles as a self-detection signal: billion-context client extensions (billion-context-pi / opencode-acp) recognize it in their own baseUrl and self-disable, so you never get double compression.

MITM transparent proxy (login clients)

Clients you sign into an account (ChatGPT Plus/Pro, Claude, ZCode coding plan, …) authenticate via OAuth and often hardcode the endpoint — if you can't change the baseURL, the prefix trick doesn't work. These use MITM mode instead.

How it works: the client only offers an HTTP proxy setting, so it sends CONNECT <host>:443; billion-context terminates the TLS locally (with a locally-generated root CA), injects compression into the cleartext, re-encrypts and forwards. The OAuth token travels in the client's Authorization header, which is forwarded untouched — so the subscription discount is preserved.

Supported MITM clients:

Client Login Endpoint hardcoded Status
ZCode bigmodel coding plan (OAuth) open.bigmodel.cn (builtin provider) ✅ tested
Claude Code Claude subscription (OAuth) api.anthropic.com ❓ untested (may not work — needs verification)

Codex exception: Codex exposes a top-level openai_base_url config field, so the ChatGPT login version CAN use the /bili/ prefix (see above). MITM is not needed for Codex.

MITM is scoped to a whitelist of model hosts (open.bigmodel.cn, api.anthropic.com, api.openai.com, chatgpt.com). All other HTTPS hosts are blind-tunnelled — billion-context never decrypts non-model traffic.

One-time setup (trust the root CA in the client):

  1. Start the proxy once to generate the root CA:

    bili start
    ls ~/.local/share/billion-context/ca/root-ca.pem   # exists now
  2. In the client's Settings → Network / Proxy set:

    • HTTP Proxy: http://127.0.0.1:8787
    • Proxy CA certificate path: the CA file bili actually generated on this machine — ~/.local/share/billion-context/ca/root-ca.pem on Linux/macOS, %USERPROFILE%\.local\share\billion-context\ca\root-ca.pem on Windows. The ZCode card on the web UI's routing page shows the real path on this machine with a copy button — just paste it.
    • (optional) No-proxy list: localhost,127.0.0.1
    • (For ZCode specifically: Settings → Network. For Claude Code, set the HTTPS_PROXY env var and NODE_EXTRA_CA_CERTS to the CA path.)

    Windows note: ZCode on Windows does not expand ~ — a ~/... path is not found (independent of the current working directory). Enter the full absolute path, e.g. C:\Users\<user>\.local\share\billion-context\ca\root-ca.pem (#342).

  3. Restart the client. Its model traffic now flows through billion-context with compression injected. Send a message and check the proxy log (~/.local/state/billion-context/bili.log) for mitm <host>:443 tunnel established.

The root CA is generated locally and lives only on this machine; it is not a system-wide install. Only the client you configure (via its CA-path setting) trusts it, so no other app is affected. Deleting the CA files and restarting the proxy regenerates them.

To give a MITM login client its own upstream proxy (firewall/GFW) without affecting API-key clients on the same host, use the mitm:// scheme key — see the README's Upstream proxy section.


Launcher Reference

bili <client> brings up a proxy on an independent port (a fresh instance every launch — an already-running bili start is never reused, #216), then runs the client pointed at it. No config-file edits: the client's own config is read (never edited) to discover which upstream hosts it talks to; those hosts are auto-whitelisted for MITM so the proxy TLS-terminates exactly the hosts the client uses. When the client exits, a proxy the launcher started is stopped.

Both upstream schemes are covered automatically, with no config edits:

  • HTTPS upstreams → cert MITM. The client is pointed at the proxy via HTTPS_PROXY and trusts the proxy's MITM root CA (~/.local/share/billion-context/ca/root-ca.pem, generated lazily). Compression is injected on the intercepted TLS stream.
  • HTTP / localhost upstreams → /bili/ baseURL rewrite (plaintext can't be MITM'd). The launcher rewrites the client's base URL through the client's own mechanism, via an isolated temp copy of its config — the real config files are never touched (details below).

How each client is pointed at the proxy (set automatically in the child env):

Client Redirect CA trust
pi HTTPS_PROXY + BILI_PROVIDER_REWRITES env manifest (extension registerProvider) NODE_EXTRA_CA_CERTS
omp HTTPS_PROXY + BILI_PROVIDER_REWRITES env manifest (extension registerProvider) NODE_EXTRA_CA_CERTS
codex HTTPS_PROXY + -c key=value overrides SSL_CERT_FILEcombined-ca.pem
claude ANTHROPIC_BASE_URL = /bili/ URL none needed
opencode HTTPS_PROXY + isolated OPENCODE_CONFIG NODE_EXTRA_CA_CERTS
hermes HTTPS_PROXY (plain-http rides absolute-form forward-proxy requests) HERMES_CA_BUNDLEroot-ca.pem
dsh HTTPS_PROXY (+ HTTP_PROXY for plain-http) + DEEPSEEK_BASE_URL; loopback-only isolated DSH_HOME SSL_CERT_FILEcombined-ca.pem

NODE_EXTRA_CA_CERTS appends to the built-in trust store, so it points at the MITM root alone (root-ca.pem). SSL_CERT_FILE replaces the default CA bundle, so for codex it points at combined-ca.pem — a bundle containing the MITM root plus the system/Node public roots — keeping pip/git/curl style TLS (blind-tunnelled, real certificates) working inside the child env (#152).

Claude Code's undici fetch ignores HTTPS_PROXY, so cert MITM cannot intercept it. Every claude upstream — including a pre-configured ANTHROPIC_BASE_URL relay — is routed through the /bili/ URL form via ANTHROPIC_BASE_URL instead; no CA trust is required.

Where upstreams are discovered from (read-only):

Client Read from
Pi ~/.pi/agent/models.json — each provider's baseUrl
omp ~/.omp/agent/models.yml — each provider's baseUrl
Codex ~/.codex/config.toml — each [model_providers.<name>] base_url (+ top-level openai_base_url)
Claude Code ANTHROPIC_BASE_URL env var, else hardcoded api.anthropic.com
OpenCode ~/.config/opencode/opencode.json — each provider's baseURL
hermes ~/.hermes/config.yaml — each provider's endpoint lines
dsh ~/.dsh/settings.yaml — every baseURL/baseUrl/base_url value, split by destination (loopback → /bili/ rewrite; non-loopback https → MITM whitelist; non-loopback http → HTTP_PROXY); plus the built-in deepseek-official route via $DEEPSEEK_BASE_URL

Generated files (what gets written — last resort only, #535)

The launcher prefers file-free injection (env vars > CLI flags/extension APIs > generated files; see README, “Injection priority” section). Where a file is unavoidable it is a copy — the real config is never edited:

  • pi / omp — nothing is written (#535): provider baseUrls ride the BILI_PROVIDER_REWRITES env manifest consumed by the bili extension at load (registerProvider), and native compaction is cancelled in-extension (session_before_compact). The real ~/.pi / ~/.omp homes are untouched.
  • opencode — a temp opencode.json pointed at by OPENCODE_CONFIG (removed when the client exits), with /bili/-rewritten plaintext baseURLs plus the thin /acp plugin appended (native tools out of the box; the standalone opencode-acp plugin self-disables via BILLION_CONTEXT_PROXY).
  • hermes — nothing is written (#535): its httpx stack rides HTTPS_PROXY (+ HERMES_CA_BUNDLE) — https via CONNECT cert-MITM, plain-http via absolute-form forward-proxy requests. If no providers are configured, the launcher prints a warning and hermes runs unproxied (compression off).
  • dsh — split by destination (#535): dsh's fetch stack honors proxy envs except for an unconditional loopback bypass, so non-loopback upstreams ride HTTPS_PROXY (cert MITM) / HTTP_PROXY (absolute-form forward-proxy requests) with SSL_CERT_FILEcombined-ca.pem; only loopback upstreams keep the persistent overlay DSH_HOME (~/.dsh-bili) with a rewritten settings.yaml routing them through /bili/. profiles/, credentials and sessions are symlinked through; the real ~/.dsh is never touched. The built-in deepseek-official route is captured separately via $DEEPSEEK_BASE_URL (dsh resolves settings llm-deepseek.baseURL ?? env ?? default, so a user setting wins and the env is the zero-config fallback) — with no custom providers the deepseek route is still proxied out of the box.

Native tools in the launcher

  • pi — if the plugin is NOT installed, the launcher rides pi's -e <file> flag to load dist/agent/pi.js for that run only (nothing is written): native tools + the /acp command out of the box. If it IS installed, the symlinked settings.json already loads it — no -e is added.
  • omp — does NOT ship the plugin; the launcher auto-injects -e dist/agent/omp.js when the config carries no loadable bili entry (same zero-config ride as pi). Two omp-specific mechanics make the plugin fully native there: omp 17.x mounts extension tools that omit loadMode under its xd:// device URLs (invisible to the model's main turn), so the plugin registers its tools with loadMode: "essential" — the model gets the four ACP tools natively; and since omp's fork emits no before_provider_headers, the plugin binds the conversation via the launcher identity register (POST /__bili/plugin/register, keyed by omp's session id = prompt_cache_key/x-session-id) — bound sessions run in plugin mode (wire injection suppressed) with the native /acp command.
  • opencode — the temp config appends the thin plugin automatically.
  • claude / codex — on by default: the launcher injects a single bili MCP server (--mcp-config for claude, -c mcp_servers.bili.* for codex — both ephemeral, nothing written to host config), so the host gets native tools out of the box (verified with claude 2.1.227 / codex 0.147.0). BILI_LAUNCHER_PLUGIN=0 falls back to plain wire mode — for hosts older than the verified builds that have not been tested against the injection flags.
  • codex + self-hosted upstream auto-fallback — codex 0.147 ships MCP tools to the model as a namespace tool type; self-hosted servers (sglang/vllm/ollama/llama.cpp) do not parse it, leaving the tools silently invisible. When the codex upstream host is loopback/private (127.0.0.1, RFC1918, ULA, .local, …) and BILI_LAUNCHER_PLUGIN is unset, bili automatically uses wire mode instead (flat tools every server understands) and says so on stderr. BILI_LAUNCHER_PLUGIN=1 forces plugin mode regardless.
  • hermes — no plugin API; always wire mode.
  • dsh — the launcher always splices a --patch <file> flag into dsh's argv (written to ~/.dsh-bili/.bili-acp.patch.yml), inserting dist/agent/dsh-acp.js into the profile's loader tree: the native /acp command, same shape as dsh's own /compact. Works on every profile that composes the commands service (web/tui interactive surfaces; the headless one-shot driver sends its task straight to the model and parses no commands — /compact behaves the same there). Subcommand forms are handled: dsh web gets the flag after web, dsh plugin/--dump-default-config take none.

Launcher-mode matrix:

Mode Tools surface Setup
Launcher + MCP (default for claude/codex) native MCP tools none — just bili claude / bili codex
Launcher wire mode (claude/codex, BILI_LAUNCHER_PLUGIN=0) proxy-injected wire tools one env var
Launcher -e / auto-plugin (pi, opencode; omp built-in) native plugin tools none
Manual plugin (bili plugin install) agent-side plugin run install
Manual baseURL (/bili/ prefix) proxy-injected wire tools edit client config

Direct-URL mode (opt-in)

BILI_LAUNCHER_DIRECT=1 drops MITM/CA trust entirely — claude's ANTHROPIC_BASE_URL / codex's provider base_url point at the /bili/ prefix directly. Warnings:

  • codex direct mode: the LLM traffic does not go through the proxy, so compression is not applied — only the bili MCP tool calls do. For full compression use the default MITM mode (unset BILI_LAUNCHER_DIRECT).
  • claude direct mode: ANTHROPIC_BASE_URL is overridden to the proxy; a pre-configured relay is bypassed unless BILI_CLAUDE_UPSTREAM=<relay> is set. OAuth-subscription traffic requires the default MITM mode.

--mitm-domain <domain> (repeatable) adds extra domains to the MITM whitelist beyond what auto-discovery finds — useful for hosts the client fetches at runtime rather than from its config file. The launcher picks a free port automatically if the default is taken; --passthrough / --debug / --no-auto-update work like plain bili.


Plugin Mode (native tools)

For a native-plugin experience, an agent can run a small cooperative plugin alongside the proxy: the plugin registers the four ACP tools (compress / decompress / search_context / acp_status) natively with the agent and drives the agent's own tool loop, while the proxy stays the compression authority (state, history folding, philosophy prompt, nudges). Tool schemas are served by the proxy itself (GET /__bili/plugin/manifest), so plugin and proxy can never drift. Protocol spec: PLUGIN.md.

Plugin-equipped sessions are detected automatically via request headers — wire-level tool injection is then suppressed for them (no double compression, native tool UX). Works in both proxy modes: the /bili/ prefix baseURL and MITM transparent mode. The plugin can also report the agent's own model context window (x-bili-plugin-context-window) and read live context usage via GET /__bili/plugin/status.

install / remove / list

bili plugin install pi      # add this billion-context install to pi's settings.json (packages)
bili plugin install omp     # same for omp (config.yml extensions)
bili plugin install claude  # register the bili MCP server (claude mcp add, user scope)
bili plugin install codex   # append [mcp_servers.bili] to ~/.codex/config.toml
bili plugin install opencode  # add mcp.bili to ~/.config/opencode/opencode.json
bili plugin list            # install status for every supported host
bili plugin remove pi       # undo (original files backed up to *.bili-bak once)

install pi also replaces any legacy billion-context entries (old npm:billion-context-pi references, stale npm:billion-context@x.y.z, leftover dev-checkout paths) so exactly one bili plugin stays live.

The installed plugin is a thin one (~5 KB, zero runtime deps): it detects the proxy (from the /bili/ baseURL or BILLION_CONTEXT_PROXY), fetches tool schemas from the proxy, registers native tools, and forwards executions — the proxy remains the single compression authority, so plugin and proxy always match versions. Hosts without a plugin API (claude, codex, opencode) install the MCP bridge (dist/mcp.js) instead — same protocol underneath, though MCP has no slash-commands (no /acp panel command).

Kill switch: BILLION_CONTEXT_PLUGIN=0 disables plugin mode entirely (wire-level injection resumes).

When do you need plugin install at all? Launcher users mostly don't (see Launcher Reference — pi/omp get -e auto-injected, opencode auto-injects, claude/codex get the MCP server auto-injected, dsh gets the native /acp command via --patch, hermes is wire-only). It's for a manually-configured client (/bili/ prefix or MITM) where you want the native panel: pi/omp/opencode get native tools + /acp (on omp the fork hides extension tools from the model — the plugin's value there is the /acp command); claude/codex get native MCP tools (no /acp); dsh gets /acp through the launcher's --patch (a manually-configured dsh can add the same patch itself); hermes can't (wire only). Without any plugin everything still works — compression runs via wire-injected tools, and the model can be asked to call acp_status to check live usage.


Sessions & Migration

Compression state lives in the proxy (#151)

Compression state (blocks, summaries, original message cache) lives in the proxy, not in the client. The client's own local history is the full uncompressed view. Two consequences:

  • If you point the client back at the real upstream (or stop the proxy), the client replays its full local history every turn. After a long compressed session this can exceed the model's context window (context_window_exceeded).
  • There is no way to "unpack" a compression block into the client's local history — the client never saw the compressed form.

Storage lifecycle at boot (#401)

The proxy boots session storage with a single directory walk+parse (load and the one-time #286 identity migration run over the same parsed map). Sessions are kept permanently — the design goal is that a year-long conversation is never lost — so there is no retention or size-budget pruning. The #286 migration writes a .bili-migration-286.done marker after its first pass, so it never re-scans or re-logs on later boots.

Migrating off the proxy

Export the session and paste it into a fresh conversation as a handoff:

bili export                      # list persisted sessions (id, label, blocks)
bili export <id|label>           # print a Markdown handoff (block summaries)
bili export <id> --full          # include the original messages per block
bili export <id> --full --output handoff.md

Then start a new conversation in the client (direct to upstream) and paste the handoff doc as the opening context.

Codex subagents get their own compression namespace (#150)

Codex subagents (e.g. the guardian_subagent approval reviewer) reuse the main conversation's session_id, so on the wire they look like the same session. Without care their requests inherit the main conversation's compression state — a subagent turn can get its context folded (losing the verbatim user authorization it must read back) and the two roles' usage estimates pollute each other.

billion-context detects this via the instructions field: subagent requests carry their own role prompt. The first instructions seen for a conversation anchor the main namespace (stable even if the main prompt drifts); any other instructions value maps to a separate |sub: namespace with its own empty compression state. Subagent requests are self-contained replays, so the fresh namespace is lossless — and the web UI's session list shows the two namespaces as separate sessions sharing the same client label.

Windows: exclude the sessions dir from antivirus (#362)

billion-context persists each session's compression state to one JSON file per session under the sessions dir (%USERPROFILE%\.local\share\billion-context\ by default) and rewrites that file on every turn of a long session. On Windows, real-time antivirus (Windows Defender), the search indexer, or a sync tool (OneDrive) can lock that directory mid-write. When the lock holds across several writes, the rename fails with EPERM and every persist for that session fails until the lock is cleared.

When the same session fails N consecutive writes (default 5, tunable via BILI_PERSIST_EPERM_ALERT_THRESHOLD), the proxy logs a one-time, actionable alert naming the exact directory to exclude. It does not repeat (set BILI_PERSIST_EPERM_ALERT_REPEAT_MS > 0 to re-alert at most every M minutes while the failures continue).

To stop the failures at the root, add the sessions dir to your antivirus exclusions and keep it out of any sync folder:

  1. Windows Defender exclusions: Settings → Privacy & security → Windows Security → Virus & threat protection → Manage settings → ExclusionsAdd an exclusionFolder → select %USERPROFILE%\.local\share\billion-context\.
  2. Do not sync this directory. Make sure OneDrive (or Dropbox / Google Drive / similar) is not syncing %USERPROFILE%\.local\share\billion-context\. If it lives under a synced folder, relocate it with BILI_SESSIONS_DIR to a non-synced path.

High-frequency persist writes otherwise re-trigger the real-time scan on every turn — which is what produces the EPERM write failures. Once the directory is excluded, the alerts stop.