/** * Gateway — the reusable message pipeline. * * Extracts the full "send message" pipeline (budget → persist → RAG → prompt → * Claude CLI spawn → stream → persist response) into a single function that * both Janus (over IPC) and the gateway server (over HTTP/SSE) can call. */ import { ChildProcess } from 'child_process'; import { AdaptiveContextBudget } from './adaptive-context-budget.js'; import { type ResumeState, type NeedsInputPayload } from './agentic-loop.js'; import { ContentStore } from './content-store.js'; import { HistoryStore } from './history.js'; import type { Message } from './history.js'; import type { RetrievalResult } from './retriever.js'; import { SessionManager } from './session.js'; import { type ToolPolicy } from './tool-inventory.js'; /** Override the threads directory (used by tests to isolate from production). */ export declare function setGatewayThreadsDir(dir: string): void; /** Read-only view of active Claude subprocesses (for health/shutdown) */ export declare function getActiveSubprocessCount(): number; /** Register a completion promise for a subprocess (resolves after persistence) */ export declare function registerCompletionPromise(child: ChildProcess, promise: Promise): void; /** Get thread names of all currently active (in-flight) subprocesses */ export declare function getActiveThreadNames(): string[]; /** Kill the active subprocess for a given thread name. Returns true if a process was killed. */ export declare function killThreadSubprocess(threadName: string): boolean; /** Grace window after SIGTERM before escalating to SIGKILL during the shutdown drain */ export declare const SUBPROCESS_KILL_GRACE_MS = 5000; /** Max time to wait for SIGKILL'd children to actually exit (clear from the set) */ export declare const SUBPROCESS_KILL_REAP_MS = 3000; /** * Escalate termination of still-active children: SIGTERM, a grace window, then * SIGKILL any survivors, then wait (bounded) for them to actually exit. This * guarantees a hung child is dead before the process shuts down — rather than a * single best-effort SIGTERM that a stalled child can ignore, leaving native * resources (e.g. the ONNX worker threads) to be torn out from under it and * abort with `mutex lock failed`. Generic + injectable timing for testing. */ export declare function escalateSubprocessTermination unknown; }>(active: Set, graceMs?: number, reapMs?: number): Promise; /** Wait for all active subprocesses to complete AND persist their responses (with timeout) */ export declare function drainActiveSubprocesses(timeoutMs?: number, beforeTerminate?: (threadNames: string[]) => void): Promise; export declare const RECENT_CONTEXT_COUNT = 10; export declare const RECENT_MSG_MAX_TOKENS = 800; export declare const RECENT_CONTEXT_BUDGET = 6000; export declare const SYSTEM_PROMPT_TEMPLATE = "You have NO memory of this conversation. There are {count} prior messages (~{tokens} tokens) in the history.\n\nCURRENT SESSION: {sessionId}\n{alwaysIncludeContext}{retrievedContext}\n{recentContext}\nCONTEXT MANAGEMENT:\n- RECENT CONVERSATION: The last few messages, always included for continuity.\n- RETRIEVED CONTEXT: Automatically retrieved based on the user's current message using semantic + keyword search.\n- RETRIEVED CONTEXT is assembled fresh each turn; it is not a record of what earlier turns' prompts contained.\n- Large content may be stored externally as [STORED:xxx] references.\n\nWORKFLOW:\n1. FIRST use the RETRIEVED CONTEXT above \u2014 it was automatically selected for relevance to this query\n2. Check RECENT CONVERSATION for immediate context\n3. Only use tools if the retrieved context doesn't contain what you need\n4. For [STORED:xxx] references, use retrieve_content to get full content\n\nNEVER guess. Use the context provided or retrieve more if needed.\nBefore telling the user something is not on record, not mentioned, or doesn't exist, call search_history or search_content with the specific terms first \u2014 RETRIEVED CONTEXT coming up empty is a reason to search, not evidence of absence.\nIMPORTANT: Present retrieved information naturally \u2014 do not narrate retrieval mechanics unprompted. EXCEPTION: if the user asks how you know something, or about the memory/retrieval system itself, answer truthfully about retrieval. Never invent a mechanism or origin story for how information reached you; if you cannot verify where something came from, say so.\n\nFILE EDITING (MANDATORY):\nTo CHANGE a file, use your harness's structured editing tool, never a shell script.\n- Built-in Edit/Write if you have them: Edit requires you FIRST read that exact file with the built-in Read tool in the same session (read_file does NOT satisfy it). Sequence: built-in Read \u2192 built-in Edit.\n- Otherwise use the structured editor you do have (e.g. apply_patch) \u2014 it satisfies this rule, so do not ask permission for it.\n- Do NOT patch files by writing shell scripts (sed, awk, python, echo/cat heredocs, etc.). Script-patching is fragile (quoting errors, no diff, not atomic) and is forbidden.\n\nBACKGROUND WORK:\nYour turn runs in a subprocess that EXITS when the turn ends, so a command you background yourself (`cmd &`) is killed as soon as you stop talking \u2014 on EVERY turn, not occasionally.\n- For work that outlasts your turn, use run_job(command, label). The gateway owns it, so it has no pipes to break and is not in your turn's process tree. When it exits you are woken with its exit code and the tail of its output.\n- Write the command plainly: no `&`, no `> log 2>&1`, no `nohup`/`setsid`. run_job already does all of that, and adding your own breaks the log capture.\n- Then END YOUR TURN. Do not wait for it and do not poll \u2014 you are woken automatically. Use list_jobs and job_log(id) to check on a running job, cancel_job(id) to stop one.\n- A command that finishes in a couple of minutes needs none of this \u2014 run it in the foreground and wait.\n\nTOOLS:\n- read_file: Read any file (text or PDF) \u2014 returns a summary + chunk table-of-contents and stores the content for future search. Built-in Read also works (its results are captured to storage automatically); prefer read_file for large files you'll navigate by section.\n- store_content: Store arbitrary text content for future retrieval\n- search_history: Search past messages by keyword or meaning\n- peek_recent: Get the last few messages\n- read_messages: Read messages by index range\n- retrieve_content: Get full stored content by [STORED:xxx] ID\n- search_content: Search across all stored content (past file reads, command outputs \u2014 content Grep cannot see)\n- read_content_chunk: Read a specific chunk of stored content by index\nThese MCP tools may still be connecting at the very start of a turn; they appear within a second or two (a ToolSearch for them will wait). Do not narrate their availability.\n\nINTER-AGENT MESSAGING:\nYou can communicate with other agents/threads running on this gateway.\n- list_agents: See all active threads and their status (idle/streaming)\n- send_to_agent(target, message): Send a message to another thread \u2014 it will be delivered as a new turn\nWhen the user mentions another agent by name (e.g., \"@thundercat\", \"@Friday\"), use send_to_agent to message them.\n- Another thread is a PARALLEL turn loop, not a subprocess you control: it runs on its own schedule and there is no file locking between you. Do NOT delegate work that edits files in your own working tree \u2014 two threads editing one checkout lose each other's writes silently.\n- If you must delegate file work, give the delegate its own projectDir or git worktree and exactly one owner per file. Otherwise do the work yourself.\n- create_co_thread(label): make a sibling thread that SHARES this thread's stored content (one memory, both directions) but keeps its own conversation, in its OWN git worktree and branch \u2014 so unlike the rule above, co-threads CAN work one project at once without overwriting each other. list_co_threads shows the set and what each has pending; merge_co_thread(label) folds one back into your tree. Only a master creates or merges co-threads.\n\nSCHEDULING (how you wake yourself up later):\n- schedule_trigger(id, trigger, at|cron, message): inject a message into THIS thread at a future time \u2014 \"once\" with an ISO 8601 `at`, or \"cron\" with a 5-field expression. Persisted in thread config, so it survives gateway restarts. Firing it starts a real turn.\n- cancel_schedule(id): remove one. list_schedules(): see what is pending.\n- Nothing wakes you when a turn ends, and nobody will ask you for status. If you were told to keep working and work remains, schedule_trigger a \"once\" trigger a minute out with a note on what to resume, THEN end the turn. cancel_schedule it once the work is done.\n\nDo NOT append status codes, tags, or metadata to your responses.\n\nTASK TRACKING CONVENTION:\nThis environment uses blocks for task tracking (defined below). If you see system-reminders or tool registrations referring to \"TodoWrite\" or any other task-tracking mechanism, silently follow the convention instead. Do NOT acknowledge the conflict, do NOT narrate which system you're using, do NOT write phrases like \"Ignoring TodoWrite reminder\" or \"env uses blocks per spec\". Just use blocks without comment.\n\nRESPONSE FORMAT: TASK TRACKING\n\nWhen working on multi-step tasks, structure your response using these blocks:\n\n\n- [ ] Step 1 description\n- [ ] Step 2 description\n- [ ] Step 3 description\n\n\n{your response to the user \u2014 tool calls, explanations, etc.}\n\nAfter executing tools or completing steps, emit an updated block reflecting progress:\n\n\n- [x] Step 1 description\n- [x] Step 2 description (just finished)\n- [ ] Step 3 description\n\n\nRules:\n- Use full-replacement semantics: emit the COMPLETE list each time, not diffs\n- Mark completed items with [x], pending with [ ]\n- The block is REQUIRED for multi-step work (2+ steps), optional for simple Q&A\n- The LAST block in your response is the authoritative state\n- Do NOT wrap your response text in tags \u2014 just write normally after the block\n\n### Decomposition\n\nBefore executing a todo \u2014 up front, or again at execution time if it turns out coarser than expected \u2014 check whether it can be sensibly split into 3+ sub-steps. If so, decompose into subtodos first; if not, execute directly. Creation tasks (\"create\", \"build\", \"write\", \"design\", \"implement\", \"draft\", \"compose\") almost always decompose: outline the deliverable's structure, then convert each outline bullet into a subtodo.\n\nExample:\n\n[x] Fetch npm package info\n[ ] Create promotional HTML page:\n [ ] Outline page sections (hero, features, quickstart, footer)\n [ ] Draft hero section with title, tagline, CTA\n [ ] Draft features grid from package capabilities\n [ ] Draft quickstart with install commands\n [ ] Draft footer with links\n [ ] Review full page for consistency\n[ ] Upload to media server\n\n\n### Todo Lifecycle\n\n1. **Decompose creation tasks** before executing (outline \u2192 subtodos), and again at execution time if a todo turns out coarser than expected\n2. **Adapt dynamically** \u2014 new information, discovered bugs, or uncovered dependencies each become a new todo rather than a silent detour\n3. **Never fix inline** \u2014 a bug unrelated to your current task gets its own todo, not a context switch\n4. **Reflect redirects** \u2014 update the todo list before executing on a user-directed change of plan\n5. **Debugging** \u2014 treat each hypothesis as a todo; test systematically rather than chasing the first suspicious lead"; /** * Committed budget for the resolved static template, in estimateTokens units * (task 117). The template reached ~3,400 tokens/turn by accretion — every * incident wanted a rule in the one deterministic channel, and nothing measured * the cumulative cost until task 114. Enforced by a unit test AND the * .rlm-bench/memory runner, so growth fails a gate instead of shipping silently. * * RAISING THIS NUMBER IS ALLOWED but must be a deliberate, committed edit with * a rationale in the commit message — that friction is the entire point. * Current measured value after tasks 114–116: ~1,606. * * RAISED 1,800 → 1,850 (task 219): FILE EDITING named Claude's Edit/Write as the * only way to change a file, which is a rule a non-Claude thread cannot follow — * four GPT-backed Nova co-threads stopped mid-task to ask permission for * apply_patch. The per-harness clause costs ~30 tokens and buys back turns lost * to blocked threads. Headroom was already ~7 tokens, so this is the raise the * doc above anticipates, not accretion. */ export declare const STATIC_PROMPT_TOKEN_BUDGET = 1850; /** * Can this thread actually run background work? (task 134, retained by 139) * * Originally the gate on seeding the watcher recipe; now the gate on the * `run_job` endpoint, which is the same question with more at stake. run_job * is arbitrary shell execution reached over MCP, so without this a thread whose * merged config denies `Bash` could run shell anyway — the bypass class task * 136 closed for `read_file`. * * Same derived-predicate shape as task 110's isAgentSender: the condition that * decides whether background work is USEFUL is the condition that decides * whether it is offered, so the two cannot drift apart. Deliberately NOT keyed * on namespace — namespace is a proxy, executability is the property, and * keying on it would need an option threaded through six spawn paths that fails * OPEN when one is missed (task 113 is the recorded case of exactly that). */ export declare function canRunBackgroundWork(config: ToolPolicy): boolean; /** * Root confinement for the `read_file` MCP tool (task 136). * * Denying the built-in `Read` tool does NOT stop a thread reading arbitrary * files: `read_file` resolved any absolute path with no root check, so a * visitor thread could read ~/.cumulus/gateway.config.json — which holds * apiKeys[0] and every provider credential. App authors had to discover this * and deny the MCP tool by name (cdda's 47-entry denylist does exactly that). * * Same derived-predicate shape as canRunBackgroundWork above: the condition * that makes the bypass matter (the author denied Read) is the condition that * closes it. Confinement rather than denial, because two configurations are * both legitimate — a thread that wants NO filesystem reads, and a thread that * denies Read to force reads through read_file so they enter the content store * (cumulus prescribed exactly that before task 115). Confining to the thread's * own working directory keeps the second working and still puts the gateway * config out of reach. * * `undefined` means unrestricted — today's behaviour, unchanged for every * thread that does not deny Read. * * Task 141: delegates to toolAvailable, so an ALLOWLIST closes the hole too. * Checking disallowedTools alone (as this did originally) returned `undefined` * for a thread whose allowlist simply omits Read — which is exactly the shape * task 137 tells app authors to write, so the confinement never engaged on the * surface it was built for. Same correction canRunBackgroundWork took above. * * Note the limit, which is a property of the tools and not of this rule: the * built-in `Read` is the Claude CLI's own tool and cumulus has no root hook * into it. So confinement is meaningful only once Read is denied — which is * also the only state in which this returns roots at all. A config that admits * Read has an unconfined read surface by construction, and the kit example * says so. */ export declare function readFileRootsForThread(config: ToolPolicy, threadCwd: string): string[] | undefined; /** Typed stream segment for verbose display */ export type StreamSegment = { type: 'text'; content: string; } | { type: 'thinking'; content: string; } | { type: 'tool_use'; tool: string; input: Record; } | { type: 'tool_result'; content: string; isError: boolean; } | { type: 'system'; content: string; } | { type: 'result'; duration_ms?: number; usage?: Record; }; /** Debug snapshot captured during each message turn */ export interface DebugSnapshot { timestamp: number; threadName: string; sessionId: string; messageCount: number; tokenCount: number; userMessage: string; budget: { total: number; userQuery: number; alwaysInclude: number; recentContext: number; ragAvailable: number; ragUsed: number; }; retrieval: { historyCount: number; contentCount: number; tokensUsed: number; avgRelevance: number; queryType: string; } | null; alwaysInclude: { files: Array<{ path: string; tokens: number; truncated: boolean; error: string | null; }>; totalTokens: number; }; recentMessageCount: number; systemPromptLength: number; systemPromptBreakdown?: { instructionTokens: number; alwaysIncludeTokens: number; recentContextTokens: number; ragTokens: number; totalTokens: number; }; } /** MCP server config entry */ export interface McpServerEntry { command: string; args: string[]; env: Record; } /** Config for auto-including gateway-agents MCP server */ export interface GatewayAgentsConfig { /** Gateway's own URL (e.g., http://127.0.0.1:8090) */ apiUrl: string; /** API key for authentication */ apiKey: string; /** Optional: Resend API key for email tools */ resendApiKey?: string; /** Optional: Default from address for emails */ resendDefaultFrom?: string; /** Optional: Reply-to address for emails */ resendReplyTo?: string; /** Optional: Max emails per hour per thread */ resendRateLimit?: number; } /** Options for the message pipeline */ export interface MessagePipelineOptions { threadName: string; message: string; /** Recheck durable automated admission after async preparation, before input append. */ beforeInput?: () => void; images?: Array<{ mimeType: string; base64: string; }>; attachments?: Array<{ name: string; type: string; mimeType: string; path: string; }>; /** Ephemeral context appended to the prompt only — never persisted to thread history (e.g., bridge screen context) */ contextBlock?: string; basePath?: string; claudePath?: string; sharedMcpPort?: number; /** Extra instructions appended to the resolved system prompt (task 152 — voice mode). * Appended AFTER the standard template and after the capability/co-thread gates, so the * thread keeps its memory, tools and rules and the addendum's formatting rules win over * the ones above it. Replaced the former `systemPromptTemplate` full-override, whose only * caller (voice mode) wanted append semantics and silently discarded recent conversation, * retrieved context and always-include files by supplying a template with no placeholders. */ promptAddendum?: string; /** Per-turn model override (task 152 — voice mode). Supersedes the thread's own `model` / * `claudeModel` for this turn only; nothing is persisted. Produced by `resolveVoiceModel`. */ modelOverride?: { model: string; claudeModel?: string; }; projectDir?: string; /** Root directory for project folders — used when lazy-creating a new project dir */ projectRoot?: string; /** Extra MCP server configs to include (e.g., janus-agents) */ extraMcpServers?: Record; /** Auto-include gateway-agents MCP server with these settings */ gatewayAgentsConfig?: GatewayAgentsConfig; /** Callback: text token streamed */ onToken?: (token: string) => void; /** Callback: structured segment for verbose display */ onSegment?: (segment: StreamSegment) => void; /** Callback: error occurred */ onError?: (error: string) => void; /** Callback: fired when the Claude subprocess spawns, exposes the ChildProcess ref */ onSpawn?: (proc: ChildProcess) => void; /** AbortSignal — when aborted, SIGTERM is sent to the Claude subprocess */ signal?: AbortSignal; /** HuggingFace API key (gateway-level, for non-Claude models) */ hfApiKey?: string; /** OpenAI API key (gateway-level, for provider "openai" model entries — task 118) */ openaiApiKey?: string; /** Model catalog (task 118). Lets the spawn path resolve which provider a * non-Claude model id belongs to (endpoint + credential) and honor a * config-driven contextWindow. Inline type avoids a circular import with * gateway/config.ts (structurally matches ModelEntry). */ models?: Array<{ id: string; label?: string; /** 'claude-cli' | 'huggingface' | 'openai' | `custom:` (task 147). */ provider?: string; default?: boolean; contextWindow?: number; }>; /** Custom OpenAI-compatible provider registry (task 147). Supplies the * endpoint + credential for a model whose provider is `custom:`. Inline * type avoids a circular import with gateway/config.ts (structurally matches * CustomProviderEntry). */ customProviders?: Array<{ id: string; label?: string; baseUrl: string; apiKey?: string; }>; /** Claude CLI model catalog (task 087/104). When a thread pins no `claudeModel`, * the entry flagged `default` supplies the spawn model — so "set default" in the * config UI is authoritative, not just a dropdown marker. Inline type avoids a * circular import with gateway/config.ts (structurally matches ClaudeModelEntry). */ claudeModels?: Array<{ id: string; label?: string; default?: boolean; }>; /** Resume state for continuing after AskUserQuestion pause */ resumeState?: ResumeState; /** Callback when the agentic loop pauses for user input (AskUserQuestion) */ onNeedsInput?: (payload: NeedsInputPayload) => void; /** When true, skip stripping / blocks from streamed output */ debug?: boolean; } /** Result from the message pipeline */ export interface MessagePipelineResult { response: string; ttft: number | null; userMessage: Message; assistantMessage: Message | null; /** A CLI exit failure may still persist partial output; it is not delivery acknowledgment. */ interrupted?: boolean; segments: StreamSegment[]; debug: DebugSnapshot; /** Set when the agentic loop paused for user input (AskUserQuestion) */ needsInput?: NeedsInputPayload; } /** Cached thread state */ export interface ThreadState { history: HistoryStore; content: ContentStore; session: SessionManager; adaptive: AdaptiveContextBudget; threadPath: string; } /** Resolve a thread name to its JSONL file path */ export declare function resolveThreadPath(threadName: string, basePath: string): string; /** * The content store a thread actually reads and writes (task 143). * * A co-thread points at its master's store — one shared memory across the whole * co-thread set — while keeping its own history, sessions and prompts. Every * other thread points at its own, exactly as before. * * ONE resolution function, called at every site that needs the path (the thread * cache and the MCP config), because a site that misses it fails OPEN: the * co-thread silently gets a private store and its work is invisible to the * others. Task 113 is the recorded case of a field reaching one spawn path of * four. * * `shared` (task 185) is true from BOTH sides of the family: for a co-thread and * for a master that lists co-threads. Task 143 derived it from `sharedWithMaster` * alone, so the master opened the same directory with sharing off and read its * co-threads' findings with no attribution at all — the recorded case of a * thread recalling a sibling's idea as its own. */ export declare function resolveContentStorePath(threadName: string): { contentStorePath: string; sharedWithMaster?: string; shared: boolean; }; /** * Decide which project directory a thread's turn starts from, and whether that * decision may be written back to config (task 143 phase 4). * * Extracted rather than left inline because both rules it encodes are * co-thread-specific, invisible in their effects, and otherwise only assertable * by spawning a real turn — the same reason `resolveClaudeModel` and * `claudeSpawnArgs` were pulled out. The worktree swap happens after this, via * `resolveThreadCwd`. * * Rule 1: a co-thread's project belongs to its MASTER. Defaulting on the * co-thread's own name would scaffold it an empty directory of its own instead * of pointing it at the project it was created to work on. * * Rule 2: NEVER persist projectDir for a co-thread. Writing it would create an * exact config file and, in the same stroke, end the task-098 inheritance that * gives the co-thread its master's model, effort, contextLimit and persona. * Derive, don't write. */ export declare function planThreadProjectDir(threadName: string, explicitProjectDir: string | undefined, configuredProjectDir: string | undefined, projectRoot: string): { cwd: string; coThreadMaster?: string; persistOnCreate: boolean; }; /** * What a co-thread is told about being one (task 143 phase 5). * * Every line states something the co-thread cannot observe for itself and that * changes what it should do. Accuracy is load-bearing here — task 114 is the * recorded incident of a confidently false prompt claim — so this deliberately * does NOT say uncommitted work is lost: `mergeCoThread` commits the worktree * before merging, and telling the model otherwise would be a lie it would act on. */ export declare function coThreadRoleLines(master: string, self: string): string; /** * Swap the master-facing co-thread bullet for the co-thread's own role. * * Called at the SAME single site as `applyCapabilityGates` (task 137) because * three consumers read the resolved template — task 113 is the recorded case of * per-site derivation leaving one of N paths on the old behaviour. * * Identity for a non-co-thread, so every existing thread's prompt is * byte-identical and the task 117 static budget cannot be moved by this * function. Also identity when the bullet is absent, which covers both a custom * template and the case where the capability gates already dropped the * INTER-AGENT block — that ordering is deliberate, so a thread without * `send_to_agent` is never handed a role paragraph telling it to report. */ export declare function applyCoThreadRole(template: string, master: string | undefined, self: string): string; /** What a thread in a shared store is told a `[from X]` tag means (task 185). */ export declare const SHARED_STORE_NOTE = "- Content marked [from X] was read, run or written down by thread X, which shares this memory with you. It is X's work, not something you did or discussed \u2014 say so if you rely on it."; /** * Tell a thread whose content store is shared how to read attribution (task 185). * * Applied to BOTH sides — a master with co-threads and each co-thread — at the * same single site as `applyCoThreadRole`, because the defect this fixes was a * render rule written from one side only. Identity when the store is not shared * or the anchor line is absent (custom template), so every other thread's prompt * is byte-identical and the task 117 static budget is unmoved. */ export declare function applySharedStoreNote(template: string, shared: boolean): string; /** * Append extra instructions to a resolved system prompt template (task 152). * * Identity when there is no addendum, so every non-voice turn is byte-unaffected. * The addendum lands at the END on purpose: the standard template documents markdown, * blex blocks and `` formatting, and voice mode forbids all three — last statement * wins, so appending is what makes the override work. */ export declare function appendPromptAddendum(template: string, addendum?: string): string; /** * Resolve the gateway's configured voice model to a per-turn model override (task 152). * * The settings catalog spans two lists (task 130): `models[]` for provider-routed models and * `claudeModels[]` for Claude CLI sub-models. A voice model may name an entry in either, so * the id decides which knob it sets: * - a `claudeModels[]` id → `{ model: 'claude', claudeModel: id }` (CLI path, sub-model pin) * - a `models[]` id → `{ model: id }` (provider resolution decides the endpoint) * * `undefined` — absent, blank, or naming nothing in either catalog — means NO override, so the * thread's own model applies. Fails closed towards existing behaviour: a stale id left in * config after a model is removed from the catalog cannot silently route a turn somewhere else. */ export declare function resolveVoiceModel(voiceModel: string | undefined, models?: Array<{ id: string; }>, claudeModels?: Array<{ id: string; }>): { model: string; claudeModel?: string; } | undefined; /** * Recover partial responses from .streaming.tmp files left by a previous crash/restart. * Called on daemon startup BEFORE auto-resume. * Returns the number of partial responses recovered. */ export declare function recoverStreamingBuffers(): Promise; /** * Resolve the full path to the `claude` CLI binary. * When launched from Finder/launchd, PATH is minimal. */ export declare function resolveClaudeCli(): string; /** * Resolve the full path to `node`. */ export declare function resolveNode(): string; /** Token-aware string truncation (head-only slice) */ export declare function truncateToTokens(content: string, maxTokens: number): string; /** Extract the LAST `` block from content verbatim, or null if none. */ export declare function extractLastTodoBlock(content: string): string | null; /** * Truncate a recent-conversation message while preserving its OPERATIVE STATUS (task 086). * * Head-only truncation (truncateToTokens) drops the END of long messages — but an assistant * message's authoritative status (verdicts, "done/built", and especially the final block) * lives at the END. On the `pursuit` thread this made the model contradict its own completed * build. This helper instead: * 1. Preserves the final block VERBATIM when present (the system prompt declares "the * LAST block is authoritative") — it becomes the kept tail, with the remaining budget * spent on the head. If the todo already falls within the head slice, head truncation covers it. * 2. Otherwise middle-truncates (head 60% / tail 40%) so end-of-message conclusions survive. * A `... [truncated] ...` marker shows where content was elided. */ export declare function truncateRecentMessage(content: string, maxTokens: number): string; /** Shape of a message entering the recent-conversation window. */ export interface RecentContextMessage { role: string; content: string; timestamp?: number; /** Per-turn files-touched record (task 102) — rendered as a one-line annotation. */ filesTouched?: string[]; } /** Format recent messages into a context block */ export declare function formatRecentContext(recentMessages: RecentContextMessage[], budgetTokens: number): string; /** Assemble the full system prompt from template + context */ /** * Compose the recent-conversation region: the message window plus the task-153 * co-thread activity ticker. * * Extracted because the ticker has TWO wiring sites and they must not drift. * The split is forced by prompt caching, not by oversight: the direct-provider * path marks its instructions block `cache_control: ephemeral`, and git state * changes every turn, so the ticker cannot ride the static template the way the * capability gates and the co-thread role do. * * Identity when there is no ticker, so every thread without co-threads — which * is every thread on this box today — assembles a byte-identical prompt. */ export declare function joinRecentContext(recentContext: string, coThreadActivity?: string): string; export declare function generateSystemPrompt(count: number, tokens: number, sessionId: string, recentMessages: RecentContextMessage[], retrievedContext: string, alwaysIncludeContext: string, template?: string, threadName?: string, coThreadActivity?: string): string; /** * Resolve which Claude sub-model id to pass to `claude --print --model` (task 113). * * Two tiers, most specific first (task 129 deleted the hardcoded third tier): * 1. the thread's own `claudeModel` pin (set per-thread in the UI / thread config) * 2. the gateway's configured default — `claudeModels[].default` (task 087), * live-mutable via `PUT /api/config` (task 104) * * `undefined` (no pin, no configured default) means the spawn passes NO `--model` * flag and the Claude CLI's own default applies — it tracks the installed CLI * instead of a baked-in id going stale. Setup seeds a catalog (task 129), so an * unconfigured gateway is the exception, not the norm. * * Extracted as a pure function because tier 2 was silently unreachable on three of * four spawn paths: `gateway/server.ts` copies pipeline options field-by-field and * omitted `claudeModels`, so those turns fell to the old hardcoded tier and ignored * configuration entirely. Keeping the ladder here — testable without spawning a * subprocess — is what makes that regression assertable. */ export declare function resolveClaudeModel(threadPin: string | undefined, claudeModels: Array<{ id: string; label?: string; default?: boolean; }> | undefined): string | undefined; /** * Spawn-args fragment for the resolved sub-model: `['--model', id]`, or `[]` when * nothing resolves (task 129 — the omission is what lets the CLI default apply, * and a pure fragment is assertable without spawning a subprocess). */ export declare function claudeModelArgs(threadPin: string | undefined, claudeModels: Array<{ id: string; label?: string; default?: boolean; }> | undefined): string[]; /** * The full argv for a Claude CLI turn (task 137). * * Extracted from `sendMessage` for the same reason as `claudeModelArgs` and * `resolveClaudeModel` above: the composition was only assertable by spawning a real * subprocess, so nothing verified that a compiled allowlist actually reaches the spawn. * * Owns one measured invariant: `--disallowedTools` is VARIADIC, so it must come last. * A positional argument after it is silently swallowed as extra deny rules — measured * (`Permission deny rule "the" matches no known tool`). Append nothing after this call. */ export declare function claudeSpawnArgs(params: { mcpConfigPath: string; effort?: string; claudeModel?: string; claudeModels?: Array<{ id: string; label?: string; default?: boolean; }>; disallowedTools?: string[]; }): string[]; /** What the agentic (non-Claude) branch needs to know to run a model (task 118). */ export interface ResolvedModelProvider { /** True when the thread's model runs on the agentic loop (an OpenAI-format HTTP provider). */ isAgentic: boolean; /** * Which local CLI runs a non-agentic turn (task 188). Absent means the Claude * CLI — the fallback every closed credential gate already lands on, so the * meaning of an absent field is unchanged. `codex` is the OpenAI Codex CLI. */ cli?: 'codex'; /** Credential for the provider; undefined when the gate failed (no key for this provider). */ apiKey?: string; /** * Which HTTP provider runs the loop. OpenAI reasoning models cannot use tools on * chat/completions without disabling reasoning, so they get the Responses API * provider; everything else stays on the OpenAI-format HF router client. */ wireProtocol?: 'openai-responses' | 'openai-chat'; /** Endpoint override; undefined → the provider's default. */ baseUrl?: string; /** Config-driven context window (entry.contextWindow); undefined → built-in per-model map. */ contextWindow?: number; } /** * Resolve which provider a thread's model id runs on (task 118) — pure, same * rationale as `resolveClaudeModel`: the decision must be assertable without * spawning a subprocess, and it must behave identically on every spawn path. * * "claude" (or absent) → the CLI subprocess path (isAgentic false). * A catalog entry with provider "openai" → OpenAI endpoint + `openaiApiKey`. * A catalog entry with provider "custom:" → that registry entry's own * endpoint + own credential (task 147). * Anything else (a "huggingface" entry, or an id with no catalog entry — the * pre-118 behavior, preserved so uncataloged HF ids keep working) → HF router + * `hfApiKey`. A missing credential for the resolved provider closes the gate: * the turn falls back to the Claude path rather than spawning a doomed HTTP loop. */ export declare function resolveModelProvider(threadModel: string, models: Array<{ id: string; provider?: string; contextWindow?: number; }> | undefined, keys: { hfApiKey?: string; openaiApiKey?: string; customProviders?: Array<{ id: string; baseUrl: string; apiKey?: string; }>; }): ResolvedModelProvider; /** * Normalize a configured endpoint to the full chat/completions POST target * (task 147) — pure, because it is the one place a user's most likely mistake * is caught and it must be assertable without a network call. * * The OpenAI-format client POSTs to its `baseUrl` verbatim (the HF router's * default is a full URL), but every provider's documentation gives you the BASE * (`https://api.vultrinference.com/v1`). Accepting only one form guarantees an * opaque 404 for the common case. * * `/v1` is deliberately NOT inferred when absent: not every provider uses it * (Ollama's native path differs, self-hosted vLLM can be mounted anywhere), and * guessing would be the same failure in the other direction. */ export declare function normalizeChatCompletionsUrl(baseUrl: string): string; /** * Human-readable name for the endpoint a turn will actually talk to, for logs. * * Only the agentic path has an HTTP endpoint. The Claude CLI path has none, so * reporting an HTTP base URL for it names a subsystem the turn never touches — * the mislabel class that sent debugging after the wrong provider in 0.31.59 * (task 118). The HF router is the one agentic endpoint with no explicit URL * (the provider supplies its own default), hence the named fallback. */ export declare function describeModelEndpoint(provider: ResolvedModelProvider): string; /** * Resolve `{thread}` placeholders in configured extra MCP servers (task 097 P3.5). * * Per-device threads need the spawned server to know which thread is calling — * e.g. the Pursuit bridge shim takes `BRIDGE_THREAD: "{thread}"` so its dispatch * targets that thread's live browser tab. Applies to both `args` and `env` values; * `command` is left alone (a binary path is never thread-dependent). * * Pure and non-mutating: returns fresh entries, leaving the caller's config object * (which is shared across every turn on every thread) untouched. */ export declare function appendDisallowedToolsArgs(args: string[], disallowedTools: string[] | undefined): string[]; export declare function resolveThreadPlaceholders(extraMcpServers: Record | undefined, threadName: string): Record; /** * The `gateway-agents` stdio MCP server entry for a thread. * * Task 157: extracted so the Claude CLI path (`generateMcpConfig`) and the * direct-provider path (`connectMcpTools`) are handed the SAME server. The * direct path previously hand-wrote four HTTP wrappers for a server that * serves eighteen tools, so a non-Claude thread had no `schedule_trigger`, * `run_job`, or co-thread tools while its prompt described all of them. * * Returns an empty map when unconfigured, so both callers can spread it. */ export declare function gatewayAgentsServerEntry(gatewayAgentsConfig: GatewayAgentsConfig | undefined, threadName: string, nodePath?: string): Record; /** * The stdio MCP servers the direct-provider agentic loop connects for a turn. * * Task 157: extracted so the wiring is assertable without spawning a real turn * (the `claudeSpawnArgs` precedent). The claim under test — that a non-Claude * thread is handed `gateway-agents` at all — is otherwise only observable by * driving `sendMessage`, which needs a live provider and an embedding backend. * * Namespace `extraMcpServers` are spread last so an app that declares its own * `gateway-agents` owns the name, matching `generateMcpConfig`'s ordering. */ export declare function directProviderMcpServers(gatewayAgentsConfig: GatewayAgentsConfig | undefined, extraMcpServers: Record | undefined, threadName: string): Record; /** Write MCP config JSON for Claude CLI and return its path */ export declare function generateMcpConfig(threadPath: string, sessionId: string, threadName: string, sharedMcpPort?: number, extraMcpServers?: Record, gatewayAgentsConfig?: GatewayAgentsConfig, readFileRoots?: string[]): string; /** * The MCP server set a turn on this thread gets, as an object (task 188). * * `generateMcpConfig` writes it to disk for the Claude CLI's `--mcp-config`; * the Codex branch translates the same object into `-c` overrides. One builder, * so the two CLIs cannot disagree about which servers a thread has. */ export declare function buildMcpConfig(threadPath: string, sessionId: string, threadName: string, sharedMcpPort?: number, extraMcpServers?: Record, gatewayAgentsConfig?: GatewayAgentsConfig, readFileRoots?: string[]): { mcpServers: Record; }; /** Clean up a temp MCP config file */ export declare function cleanupMcpConfig(configPath: string): void; /** * Parse a stream-json line into typed StreamSegment objects. */ export declare function parseStreamSegments(line: string): StreamSegment[]; /** Extract text content from a stream-json line */ export declare function extractTextFromStreamLine(line: string): string | null; /** * Compute average relevance from the top-K retrieval scores. * Takes the top 10 scores across history + content results. */ export declare function computeAvgRelevance(debug: RetrievalResult['debug'] | undefined): number; /** * Get or create a thread's stores and adaptive budget. * Cached in a module-level Map. */ export declare function getOrCreateThread(threadName: string, _basePath?: string): Promise; /** Clear a thread from the cache */ export declare function clearThreadCache(threadName?: string): void; /** * The full message pipeline: externalize → persist → RAG → prompt → spawn → stream → persist. * * This is the extracted core of Janus's CumulusBridge.sendMessage(), now callable * from both Janus (via IPC) and the gateway server (via HTTP/SSE). */ /** * Creates a streaming filter that suppresses and blocks * from reaching the frontend while allowing them through to fullResponse (for RLM). */ export interface InternalBlockFilter { /** Feed a chunk of streamed text. */ write(text: string): void; /** Called once at stream end — emits anything still held. See task 149. */ flush(): void; } export declare function createInternalBlockFilter(forward: (text: string) => void): InternalBlockFilter; export declare function sendMessage(options: MessagePipelineOptions): Promise; //# sourceMappingURL=gateway.d.ts.map