Skip to content

Latest commit

 

History

History
220 lines (181 loc) · 9.93 KB

File metadata and controls

220 lines (181 loc) · 9.93 KB

acp-kernel Design

Framework-agnostic, model-driven context-compression engine. This document is the authoritative contract for the pure core.


1. Mental Model

ACP-style compression is not like zip in one essential way: the model writes the summaries; this library orchestrates everything around them.

  • zip: computes the compressed output itself (bytes → bytes).
  • acp-kernel: the summary text is produced externally (by an LLM) and passed in as an argument. The core decides when to compress, what range to compress, tracks state, applies a compress decision, prunes ranges, and supports decompress/search. It never calls a model.

This is precisely why the core can be a pure library: the one external dependency (the summarizer) is reduced to a string input.


2. Module Boundary

┌───────────────────────────────────────────────────────┐
│  CORE (pure TS, zero host dependency, MIT)             │
│  stateless w.r.t. storage: state passed in and out      │
│                                                        │
│  processTurn / applyCompression / resolveBoundaries    │
│  decompress / search / status / decideNudge            │
│  (node pipeline: assign-refs → sync → merge → prune →  │
│   filter → hide → nudge → emergency-truncate → render) │
└──────────────────────┬────────────────────────────────┘
                       │ pure function calls
┌──────────────────────┴────────────────────────────────┐
│  ADAPTER (thin, host-specific) — one per host           │
│  OpenCode adapter | Pi adapter | custom                 │
│  - pass messages into the core each turn                │
│  - own + persist the state object                       │
│  - render NudgeDecision into host message format        │
│  - register tools / commands / lifecycle hooks          │
└───────────────────────────────────────────────────────┘

The core holds no state and performs no I/O. State is an explicit input and output of every call. The host decides how to persist it (filesystem, memory, whatever). This is the zip model: zip(data) → data.


3. Domain Types

Portable, host-agnostic. Adapters translate host-native messages into CoreMessage[] before calling the core, and apply the core's output back.

type CoreMessage = {
    id: string;
    role: "user" | "assistant" | "system" | "tool";
    contentType: "text" | "tool-call" | "tool-result" | "reasoning";
    text?: string;
    toolName?: string;      // for protected-tool filtering
    toolCallId?: string;    // links tool-call ↔ tool-result
};

type CompressionBlock = {
    blockId: string;                    // "b0", "b1", ...
    runId: string;
    tier: 1 | 2 | 3;
    topic?: string;
    summary: string;                    // produced by the model
    directMessageIds: string[];
    effectiveMessageIds: string[];
    directBlockIds: string[];           // nested blocks consumed
    createdAt: number;
    survivedCount: number;
    generation: "young" | "old";
    active: boolean;
};

type CompressionState = {
    blocks: CompressionBlock[];
    messageRefs: { byRaw: Record<string, string>; byRef: Record<string, string> }; // raw ↔ mNNNNN
    nudge: {
        lastPerMessageNudgeTokens: number;
        lastNudgeShownTokens: number;
        baselineTokens: number;
        anchors: Record<string, unknown>;
    };
    stats: { tokensCompressed: number; compressionCount: number };
    nextBlockId: number;
    nextRunId: number;
};

type Config = {
    tiers: { enabled: boolean; tier2Trigger: number; tier3Trigger: number };
    nudge: {
        maxContextLimitPct: number;   // e.g. 0.55 (currently advisory; threshold gate uses minContextLimitPct)
        minContextLimitPct: number;   // e.g. 0.45 — nudge threshold gate
        frequency: number;            // advisory (reserved for future turn-frequency gating)
        iterationThreshold: number;   // advisory (reserved for future iteration gating)
        force: "soft" | "strong";
    };
    // young→old promotion after N survivals (drives the merge-blocks node).
    // NOTE: there is no GC — no age-based deactivation, no summary truncation.
    promotionThreshold: number;
    truncate: { threshold: number };          // emergency tool-output truncation node (LAST safety valve); 1.0 = 100%
    merge: { maxSummaryLength: number; minOldGenBlocks: number }; // batch-merge old-gen blocks into one summary
    protectedTools: string[];
    preserveRecentMessages: number;
    modelContextLimit: number;
};

4. Core Operations

interface CompressionCore {
    // Per-turn node pipeline (replaces the message-transform hook's algorithm part).
    // Runs every turn (canonical order): assign-refs → sync-blocks → merge-blocks →
    // prune → filter → hide-compress-calls → nudge-inject → emergency-truncate →
    // render-refs. Survives/promotes blocks via advanceSurvival (no age-based
    // deactivation). Returns transformed messages + updated state + nudge decision.
    processTurn(input: {
        messages: CoreMessage[];
        state: CompressionState;
        config: Config;
        tokenCount: number;
    }): {
        messages: CoreMessage[];
        state: CompressionState;
        nudge?: NudgeDecision;
    };

    // When the model calls compress. `ranges[].summary` is model-produced text.
    // Allocates block(s), deactivates consumed blocks, updates indices, resets the
    // nudge growth baseline on success (§5.7 feedback-loop fix).
    applyCompression(input: {
        ranges: { startRef: string; endRef: string; summary: string; topic?: string }[];
        messages: CoreMessage[];
        state: CompressionState;
        config: Config;
    }): {
        state: CompressionState;
        result: { blocksCreated: number; tokensCompressed: number; errors: string[] };
    };

    resolveBoundaries(input: {
        startRef: string;
        endRef: string;
        messages: CoreMessage[];
        state: CompressionState;
    }): { startIndex: number; endIndex: number; protectedGaps: number[] };
    // protectedGaps is reserved (currently always []); protected-tool hard-exclusion
    // is intentionally NOT implemented in the core — see README "Known limitation".

    decompress(blockId: string, state: CompressionState): CompressionBlock | undefined;

    search(query: string, state: CompressionState): CompressionBlock[];

    status(state: CompressionState, tokenCount: number, config: Config): StatusReport;

    // No gc(): age-based deactivation was removed (it caused memory-loss upstream).
    // Block promotion (young→old) still happens via advanceSurvival in sync-blocks,
    // and old-gen blocks are batch-merged by the merge-blocks node — not dropped.
}

type CompressCall = {
    mode: "range" | "message";
    ranges: { startRef: string; endRef: string; summary: string; topic?: string }[];
};

type NudgeDecision = {
    shouldInject: boolean;
    reason: string;
    compressibleRanges: { startRef: string; endRef: string; tokens: number }[];
    contextUsage: number;       // 0..1
    tier: 1 | 2 | 3 | null;     // multi-tier trigger, if any
    breakdown: Record<string, number>;
};

5. Port Surface (host implements)

The core needs two capabilities from the host, both injected (the core never imports a host SDK):

interface Ports {
    countTokens(text: string): number;   // default impl ships in core
    // messages are PASSED IN to each call (never fetched) → no MessageStore port
    // state persistence is the host's job (state is plain data) → no storage port
}

A default countTokens ships with the core (word-level + unicode CJK tokenizer, the same MIT cc-alg tokenizer). Hosts may override with a model-specific tokenizer.


6. Concept Mapping (nothing is lost)

ACP concept Destination in acp-kernel
message-id ↔ ref mapping core processTurn (pure)
prune (range → summary block) core processTurn (pure)
boundary resolution / search core resolveBoundaries (pure)
block allocation / state mutation / tiers core applyCompression (pure)
young→old promotion / batch merge core sync-blocks node (advanceSurvival) + merge-blocks node (pure)
emergency truncation (context near full) core emergency-truncate node — the LAST safety valve; no age-based GC
protected-tools filtering logic core (pure: message + config → bool)
inject decision (shouldNudge / growth / threshold) core decideNudge (pure)
inject text rendering (nudge → message string) adapter (host message format)
prompts (system / nudge text templates) rules as structured data in core; text rendering in adapter
compress/decompress/search/status tool registration adapter (calls core pure fns)
/acp commands adapter
opencode hooks OpenCode adapter
config three-layer merge adapter (core only consumes its own Config)
logger / auth / persistence / update adapter (core does zero I/O)

7. Why the algorithm, but not the DCP-derived code, comes here

Copyright protects expression, not ideas, methods, or algorithms (17 USC §102(b)). The compression methods (3-tier, growth cadence, protected filtering) are the author's. This core reimplements them in fresh expression — it is not a copy or refactor of DCP-derived files. See PROVENANCE.md for the per-module origin classification (original-bring / DCP-derived-reimplement / adapter-only).