/** * agentdox bridge — one shared project context that survives model switches. * * The router is the only choke point that sees every model in every harness, * so injecting project memory/docs/brief HERE gives every candidate model the * same context without per-harness MCP wiring. * * The load-bearing constraint is the prompt cache. A context block that * changes per turn sits at the front of the prefix and turns every turn into a * full cache miss — which would cost far more than routing saves. So a block * is PINNED per conversation and only re-fetched at moments the prefix is * already forfeit (a model switch, an escalation retry) or after a staleness * TTL. See `bridge.ts` for the policy. */ /** A pinned, prompt-ready context block. */ export interface ContextPin { /** * Content hash of `block`. Deliberately NOT agentdox's `assembledAt`: the * server re-assembles on a timer, so a timestamp changes even when nothing * about the content did, and that would break a warm cache for no reason. */ version: string; /** Text appended to the system prefix by the wire. */ block: string; fetchedAtMs: number; } export interface ContextResolveInput { /** agentdox project slug. Empty ⇒ the bridge is inert for this turn. */ scope: string; conversationKey: string; /** Version pinned by the previous turn; null on a fresh conversation. */ pinnedVersion: string | null; /** When that pin was fetched, for the staleness TTL. */ pinnedFetchedAtMs: number; /** This turn dispatches to a different slug than the last committed turn. */ modelSwitching: boolean; /** An escalation or failover retry — the prefix is cold either way. */ retrying: boolean; /** The user's ask this turn (see `context/query.ts`), used to bias agentdox relevance ranking. */ query: string; /** * This conversation has not been given a block before. Recent sessions * are included only then: they carry a previous conversation forward, but * on a refresh they would mostly be this conversation's own turns, which * the prompt already holds. */ firstFetch: boolean; /** * Group-context scope (rendered first) from `X-Agentdox-Group`. Empty ⇒ no * group layer is requested and nothing new is sent. */ group: string; /** Personal scope (rendered last) from `X-Agentdox-Personal`. Empty ⇒ no personal layer. */ personal: string; /** * The member this turn belongs to — the harness id, which a team front door * sets per user. agentdox filters the project layer's recent tail to that * member's own turns. Sent only when `group` or `personal` is also named: * a lone router has a harness id too and must not filter its own tail. */ user: string; } /** One settled turn, recorded to agentdox with the model that served it. */ export interface TurnRecord { scope: string; conversationKey: string; /** * The harness id, which a team front door sets per user. Non-empty ⇒ every * recorded message also carries a `user:` ref, so the member's * own turns can be told apart in a shared project session. */ harnessId: string; /** Title used if this is the first turn and a session must be created. */ title: string; userText: string; /** Text THIS dispatch produced. Fragments are joined across a tool loop. */ assistantText: string; /** The slug that actually served the turn — the model attribution. */ slug: string; tier: string; /** * Whether the assistant yielded control back to the user — i.e. the upstream * finish reason was NOT `tool_calls`. * * A user-visible turn is many dispatches: every tool round-trip is its own * request, and only the last carries the model's synthesis. The intermediate * ones are almost pure tool calls with a few stray words of text, and the * last *user* message does not move while the loop runs. False therefore * means "buffer this fragment, the turn is still running" — recording it as * a turn would write a near-empty answer and re-append the same user text. */ turnEnded: boolean; } export interface ContextBridge { /** False when no agentdox URL/token is configured; every call is then a no-op. */ readonly enabled: boolean; /** Resolves the block to inject. Never throws: agentdox being down degrades to null. */ resolve(input: ContextResolveInput): Promise; /** Queues a turn for write-back. Returns immediately; never blocks the turn. */ recordTurn(rec: TurnRecord): void; /** Drains the write queue. For tests and shutdown. */ flush(): Promise; /** * Housekeeping: drops stored blocks older than `maxAgeMs` that no * conversation still pins, returning the count removed. Blocks are * content-addressed and shared, so nothing else reclaims them — without this * the table grows for the life of the install. */ pruneBlocks(maxAgeMs: number): Promise; close(): void; } /** Content-addressed store of fetched blocks, so a restart keeps a warm prefix. */ /** Asynchronous throughout: the store may be a shared database, not a file. */ export interface ContextBlockStore { get(version: string): Promise; put(scope: string, pin: ContextPin): Promise; /** agentdox session id previously opened for a conversation. */ sessionFor(conversationKey: string): Promise; bindSession(conversationKey: string, scope: string, sessionId: string): Promise; /** * Drops blocks older than `maxAgeMs` that NO conversation still pins. * Returns the number removed. Referenced blocks are kept regardless of age: * deleting one would force a live conversation to refetch and re-inject * different bytes, turning housekeeping into a prompt-cache miss. */ prune(maxAgeMs: number): Promise; }