/** * Unified cache storage module. * * Two sibling on-disk caches live under one root (`~/.scoutline/` by * default; overridable via `SCOUTLINE_CACHE_DIR`): * * ```text * ~/.scoutline/ * ├── cache/ response cache entries (Provider responses) * └── tools/ tool discovery cache (consumed by mcp-client.ts) * ``` * * The response cache stores the RAW response from a Provider (before any * post-processing like truncation, format conversion, or extraction) * keyed by a hash of the command + request-affecting arguments. * Post-processing flags like --max-chars, --output-format, --extract are * NOT part of the cache key, so the same cached response can serve * multiple presentation variants. * * Defaults: 24h TTL, 100MB size cap, LRU eviction when full. Disable * per-call with --no-cache, or globally with `SCOUTLINE_CACHE=0`. * * Env-var policy: `SCOUTLINE_CACHE*` are the canonical names. The legacy * `ZAI_CACHE*`, `ZAI_MCP_TOOL_CACHE*`, and `ZAI_MCP_CACHE_DIR` variables * are accepted as lower-precedence aliases (silent aliasing — no * deprecation notice in this release). All reads are call-time (H1 fix) * so per-suite env mutations remain observable. * * P2-02 extends this module with provider-partitioned keys * (`buildProviderCacheKey`) and a `ResponseCache` adapter that lets * shared execution read and write through the same on-disk store without * duplicating TTL or eviction logic. */ import type { ProviderId } from "../providers/types.js"; /** * Environment surface consumed by the pure cache-root resolver. The legacy * aliases (`ZAI_MCP_CACHE_DIR`, `ZAI_CACHE_DIR`) are preserved so existing * operator configurations keep working silently. `XDG_CACHE_HOME` was * removed when the dotfile convention (`~/.scoutline/`) was adopted on * every platform. */ export interface CacheDirEnvironment { readonly SCOUTLINE_CACHE_DIR?: string | undefined; readonly ZAI_MCP_CACHE_DIR?: string | undefined; readonly ZAI_CACHE_DIR?: string | undefined; readonly SCOUTLINE_ISOLATED?: string | undefined; } export interface CacheDirPlatform { readonly platform: NodeJS.Platform; readonly homedir: string; readonly pid?: number | undefined; } /** * Pure cache-ROOT resolver. Accepts environment and platform explicitly * so tests can assert path resolution without touching process globals. * Returns the root directory (`~/.scoutline/`); each cache appends its * own subdirectory (`cache/` or `tools/`). Precedence: * 1. `SCOUTLINE_CACHE_DIR` (canonical) * 2. `ZAI_MCP_CACHE_DIR` (legacy tool-cache override; B3 fix) * 3. `ZAI_CACHE_DIR` (legacy response-cache override) * 4. `path.join(homedir, ".scoutline")` (dotfile default, all platforms) * * The process-backed {@link resolveCacheRoot} wraps this with live state. */ export declare function resolveCacheRootPure(env: CacheDirEnvironment, plat: CacheDirPlatform): string; /** * Single-source isolation predicate for env-derived surfaces (#157, PR * #183 F1): the canonical values are `SCOUTLINE_ISOLATED="1"` and * `"true"`. Consumers that branch on the env variable — both cache-dir * resolvers below and main()'s batch seam — must agree on the accepted * set, or an env-only `=true` run isolates the stores while skipping the * batch per-op refusals (the predicate split Kody flagged). */ export declare function isIsolatedEnv(env: CacheDirEnvironment | undefined): boolean; /** * Pure response-cache directory resolver. Under isolation * (`SCOUTLINE_ISOLATED="1"` or `"true"`), derives `/cache/isolated/`. * Returns `/cache` by default. */ export declare function resolveResponseCacheDirPure(env: CacheDirEnvironment, plat: CacheDirPlatform): string; /** * Pure tool-cache directory resolver. Under isolation * (`SCOUTLINE_ISOLATED="1"` or `"true"`), derives `/tools/isolated/`. * Returns `/tools` by default. */ export declare function resolveToolCacheDirPure(env: CacheDirEnvironment, plat: CacheDirPlatform): string; /** * Directory for response-cache entries. Always a `cache/` subdirectory * under the unified root (or `cache/isolated/` under `--isolated`). */ export declare function responseCacheDir(env?: CacheDirEnvironment): string; /** * Directory for the tool-discovery cache (consumed by mcp-client.ts). * Always a `tools/` subdirectory under the unified root (or `tools/isolated/` * under `--isolated`). Scanned by `cacheStats()` and cleared by * `clearAllCaches()` in its non-isolated view (no-arg), but never touched by the * response cache's LRU eviction loop. */ export declare function toolCacheDir(env?: CacheDirEnvironment): string; /** * Directory for async-job state files (tech-plan §3, T07 / FC-01). One * subdirectory per capability under the unified cache root — `research/` * for in-flight research tasks, `crawl/` for async crawl jobs — sibling * of {@link responseCacheDir} and {@link toolCacheDir}. * * Each file holds a single in-flight task's `requestId` so the CLI can * resume polling after Ctrl-C instead of creating a second task * (double-charge prevention). State files have their own lifecycle * (deleted on task completion or failure); they are NOT cleared by * `clearAllCaches()` and are NOT scanned by `cacheStats()` — they are * billing state, not cache entries. * * `capability` is the single path segment naming the subdirectory. It is * guarded: a non-empty segment with no path separators, no `..`/`.` * self-references, and no NUL bytes, whose resolved path stays inside the * cache root. A bad segment throws rather than silently writing billing * state outside the root. Callers pass an internal constant * (`"research"`, `"crawl"`); it is never user input. */ export declare function asyncJobStateDir(capability: string): string; /** * Call-time cache-enabled check for the RESPONSE cache (H1 fix). Honours * `SCOUTLINE_CACHE` (canonical) with `ZAI_CACHE` as a legacy alias. Read * on every cache operation so per-suite env mutations in tests remain * observable. * * Note: the legacy `ZAI_MCP_TOOL_CACHE` env var is intentionally NOT * consulted here. In v0.4.0 the tool cache's enable flag was independent * of the response cache's; mcp-client.ts still reads * `ZAI_MCP_TOOL_CACHE` directly for its own tool-cache enable check. * Aliasing it here would silently disable the response cache whenever a * user disabled the tool cache, which would break the four * `mcp-client.test.js` suites that set `ZAI_MCP_TOOL_CACHE=0` while * relying on response-cache hits. Unifying this granularity is deferred * to a future release (see tech-plan "what this plan does not decide"). */ export declare function isCacheEnabled(): boolean; /** Call-time TTL (ms) for the response cache. Default 24h. */ export declare function getCacheTtlMs(): number; /** Call-time response-cache size cap (bytes). Default 100MB. */ export declare function getCacheSizeCapBytes(): number; /** * Build a stable cache key from command + request-affecting args. * Post-processing flags (maxChars, outputFormat, extract, fullEnvelope) * are intentionally excluded so one cached fetch serves many presentations. * * T2b: an optional `env` parameter (defaulting to `process.env`) threads * the resolved credential view — built in `main` from injected env + * file-configured keys — into `getApiKey` so the cache fingerprint * follows the same credential that authorised the request. Source- * compatible: existing no-argument callers keep fingerprinting against * ambient `process.env`. The SHA-256 / filename algorithm is unchanged. */ export declare function buildCacheKey(command: string, requestArgs: Record, env?: NodeJS.ProcessEnv): string; /** * Build the exact v0.2 legacy repository cache key. Pure: the caller * MUST supply the already-resolved credential. The function never reads * `process.env` and never calls `getApiKey`. `args` is serialized in * its insertion order via `JSON.stringify`. * * The result never contains the raw credential — only the first 12 hex * chars of `sha256(apiKey)`. */ export declare function buildLegacyRepositoryCacheKey(apiKey: string, publicToolName: string, args: Record): string; /** * Build the exact v0.2 legacy reader cache key. Pure: the caller MUST * supply the already-resolved credential. The function never reads * `process.env` and never calls `getApiKey`. `args` is serialized in * its insertion order via `JSON.stringify`. * * The result never contains the raw credential — only the first 12 hex * chars of `sha256(apiKey)`. */ export declare function buildLegacyReaderCacheKey(apiKey: string, publicToolName: string, args: Record): string; /** * Read a cached value from a specific directory with a decoder function that * validates and narrows the raw JSON. Returns `null` on miss, expiry, or * disabled cache. */ export declare function readCacheInDir(dir: string, key: string, decoder: (raw: unknown) => T, ttlMs?: number): Promise; /** * Read a cached value from a specific directory without a decoder. Returns * `unknown` so the caller must narrow the result. */ export declare function readCacheInDir(dir: string, key: string, ttlMs?: number): Promise; /** * Write a cache entry to a specific directory. Serializes through an * inter-process advisory lock on the directory and evicts if over cap. */ export declare function writeCacheInDir(dir: string, key: string, data: T): Promise; /** * Read a cached value with a decoder function that validates and narrows * the raw JSON. Returns `null` on miss, expiry, or disabled cache. */ export declare function readCache(key: string, decoder: (raw: unknown) => T, ttlMs?: number, env?: CacheDirEnvironment): Promise; /** * Read a cached value without a decoder. Returns `unknown` so the caller * must narrow the result — no unsafe generic assumption is made about the * stored shape. */ export declare function readCache(key: string, ttlMs?: number, env?: CacheDirEnvironment): Promise; /** * Read a cached value from the directory derived for `env` (the * `cache/isolated//` subtree when it carries SCOUTLINE_ISOLATED). */ export declare function readCache(key: string, env?: CacheDirEnvironment): Promise; export declare function writeCache(key: string, data: T, env?: CacheDirEnvironment): Promise; /** * Clear the response cache only. Kept for backward compatibility; new * callers should prefer {@link clearAllCaches} which covers both the * `cache/` and `tools/` subdirectories. */ export declare function clearCache(): Promise<{ cleared: number; bytesFreed: number; }>; /** * Clear both the `cache/` (responses) and `tools/` (tool discovery) * subdirectories. Directories themselves are preserved. Existing * {@link clearCache} callers continue to clear `cache/` only. */ export declare function clearAllCaches(): Promise<{ responsesCleared: number; toolsCleared: number; bytesFreed: number; }>; /** * Selectors narrowing a {@link pruneCaches} run. All are optional and * AND together. When `olderThanMs` is absent the effective TTL * (`getCacheTtlMs()`) is the age threshold (DESIGN D3); `provider` and * `capability` selectors match v2 filenames only (DESIGN D2) — legacy / * non-v2 entries are selectable by age only. */ export interface PruneSelectors { readonly olderThanMs?: number; readonly provider?: string; readonly capability?: string; } /** * Optional tuning for {@link pruneCaches}. Production callers pass no * options; the defaults mirror `writeCache`'s lock discipline. Tests use * a short `lockTimeoutMs` so the D5 timeout-rejection path is exercised * without waiting the production 30s. */ export interface PruneCachesOptions { /** Lock-acquire timeout for the response-dir scan (ms). Default `DEFAULT_LOCK_TIMEOUT_MS`. */ readonly lockTimeoutMs?: number; /** Stale-lock threshold (ms). Default `DEFAULT_LOCK_STALE_MS`. */ readonly lockStaleMs?: number; /** * Instrumentation seam invoked after an entry is judged expired but * before it is unlinked. Production never passes it; tests use it to * interpose a concurrent replacement inside the stat→unlink window * and assert the revalidation guard skips the stale unlink. */ readonly beforeUnlink?: (filePath: string) => Promise; } /** * Outcome of a {@link pruneCaches} run. Counts reflect actual deletions; * per-entry failures are skipped best-effort like {@link clearSubdir}. */ export interface PruneCachesResult { readonly prunedResponses: number; readonly prunedTools: number; readonly bytesFreed: number; } /** * Prune expired entries from both caches (DESIGN D1–D6). * * Age is judged by the stored envelope timestamp, never mtime (D1): * response entries carry `ts`, tool entries carry `timestamp`. The * response scan runs inside the same `cache-write` inter-process lock * `writeCache` serializes on (D4), and a lock timeout THROWS rather than * being swallowed (D5) — prune is an explicit operator command, not a * best-effort cache write. The tool scan is lock-free (no write-lock * convention exists there) and selector-free (tool filenames are * unpartitioned); it applies the same age rule. * * A disabled cache does not stop a prune — deletion is not a cache * read/write (D6). But with NO explicit `olderThanMs`, a disabled cache * (TTL-0) means "no read freshness rule", so the prune is a zero-work * success reporting zeros. An explicit `--older-than` runs regardless. */ export declare function pruneCaches(selectors: PruneSelectors, options?: PruneCachesOptions): Promise; /** * Per-bucket cache inventory counts. Every breakdown bucket repeats * `{entries, totalBytes, live, expired}` (DESIGN D7). */ export interface CacheStatsBucket { readonly entries: number; readonly totalBytes: number; readonly live: number; readonly expired: number; } /** * Inventory both caches. The shape extends the v0.4.0 flat shape with * nested `responseCache` and `toolCache` sections (H3 fix). The * top-level `entries` and `totalBytes` fields are removed — callers * must read from the nested sections. * * Enrichment (DESIGN D7) is additive only: the response cache gains * `live`/`expired` counts and per-provider/per-capability breakdown * buckets (with a `legacy` bucket for non-v2 filenames); the tool * cache gains `live`/`expired` but has no `by*` keys (its filenames * are unpartitioned). Existing fields are byte-identical, so the * Doctor one-line summary (`formatDoctorCacheSummary`) is unaffected. */ export declare function cacheStats(): Promise<{ dir: string; enabled: boolean; ttlMs: number; sizeCapBytes: number; responseCache: CacheStatsBucket & { byProvider: Readonly>; byCapability: Readonly>; }; toolCache: CacheStatsBucket; }>; /** * Parse a `--older-than` duration into milliseconds (DESIGN D3). * * Accepted forms, mirroring `formatTtl`'s units in reverse: * `h` (hours), `m` (minutes), `s` (seconds), and a bare * `` interpreted as seconds. `N` must be a non-negative integer; * `0` is valid and means "prune everything". * * Returns `null` for any other input (unknown unit, missing number, * negative, fractional, empty). Callers translate `null` into a * `VALIDATION_ERROR` — this helper never throws. */ export declare function parsePruneDuration(spec: string): number | null; /** * Capability/provider pair decoded from a v2 cache filename. */ export interface ParsedCacheFileName { readonly capability: string; readonly provider: string; } /** * Parse a provider-partitioned cache filename (DESIGN D2). * * v2 keys are `v2.....json` * (see {@link buildProviderCacheKey}), so selector matching is a pure * string operation with zero content reads. * * The grammar is read RIGHT-to-LEFT, because two of its fields are not * fixed-width in the general case: * * - `capability` MAY carry dots. Science carries its capability * VERBATIM (`science.search` / `science.get`), so the joined * remainder — not a single field — is the capability. * - `credential-hash` MAY be empty. Science suppliers are keyless, so * their fingerprint is `""` and the key holds an EMPTY segment * (the keyless partition): `v2.science.search.openalex...json`. * * So: strip the `v2.`/`.json` guard, split on ".", and take the last * field as the request hash, the second-to-last as the credential * fingerprint (possibly empty), the third-to-last as the provider, and * everything left of it — rejoined on "." — as the capability. * * The pre-science shape was EXACTLY six segments with every middle * segment non-empty. Widening that is only safe if it does not also * loosen it, so the request hash must carry the SHA-256 house shape * (64 lowercase hex) and both the provider and the capability must be * non-empty. * * Returns `null` for every other shape — legacy (non-v2) entries, * `.tmp` staging files, `.lock` files, and malformed names. Those are * selectable by age only and bucket under `legacy` in stats. */ export declare function parseCacheFileName(name: string): ParsedCacheFileName | null; /** * Response cache surface consumed by shared execution * (`executeSearch`, future `executeVision`, etc.). Production wires * {@link defaultResponseCache} to the existing on-disk implementation; * tests inject in-memory doubles. */ export interface ResponseCache { get(key: string, decoder: (raw: unknown) => T): Promise; get(key: string): Promise; set(key: string, value: T): Promise; } /** * Inputs to a provider-partitioned cache key. `credentialFingerprint` * is the full lowercase SHA-256 hex digest of the active credential * supplied by the Adapter; it is NEVER re-hashed by cache code. * `request` is the normalized Capability request whose recursively * key-sorted JSON becomes the request hash. */ export interface ProviderCacheKeyInput { readonly provider: ProviderId; readonly capability: string; readonly credentialFingerprint: string; readonly request: unknown; } /** * Build a provider-partitioned cache key. * * Shape: `v2.....json` * * `` is the Adapter-supplied fingerprint verbatim. * `` is the full SHA-256 hex digest of recursively * key-sorted JSON of the request. The key never contains a raw * credential. */ export declare function buildProviderCacheKey(input: ProviderCacheKeyInput): string; /** * Create a directory-scoped `ResponseCache`. Accepts a fixed directory path * or a dynamic resolver function called on each get/set operation. * * `defaultResponseCache` is the non-isolated special case of this factory * wired to `responseCacheDir` so call-time root resolution is preserved. */ export declare function createFileResponseCache(dirOrResolver?: string | (() => string)): ResponseCache; /** * Default `ResponseCache` bound to the existing on-disk store. Reads * and writes flow through `readCacheInDir`/`writeCacheInDir` using call-time * root resolution, so TTL, eviction, and directory resolution remain * identical to the legacy path. */ export declare const defaultResponseCache: ResponseCache; //# sourceMappingURL=cache.d.ts.map