/** * topic-state.ts — rolling per-session topic profile for ambient recall. * * PROBLEM THIS SOLVES * ──────────────────── * hook-ambient's recall query is built ONLY from the current prompt's own * keywords. A user who spends several turns giving background ("we're * migrating the billing service... Postgres schema is the tricky part...") * then asks a short generic follow-up ("what should I watch out for?") gets * NO ambient recall on that final prompt — its own keywords are too weak to * clear the precision floor, even though the conversation's topic is crystal * clear by then. This module accumulates a decayed keyword frequency map * across the last few prompts in a session so that background context can * inform recall on a later, keyword-sparse prompt. * * DESIGN CONSTRAINTS * ────────────────── * Zero-LLM, zero-cloud, local-only — same constraint as the rest of the * ambient pipeline. State is a small JSON file per session under * `/tmp/ambient-topic-.json`. * * CJK SUPPORT * ─────────── * The hook's existing current-prompt keyword extraction (`extractKeywords` * from auto-name.ts) is Latin-only — it strips every non-ASCII character * before tokenizing, so a purely-Chinese prompt yields an empty keyword set. * `tokenize` from agent-recall-core's check-action.ts was made CJK-aware on * 2026-07-25 (Han-run segmentation via Intl.Segmenter, no length floor for * CJK tokens) — this module uses THAT tokenizer for its own extraction step * so Chinese background chat also builds a topic profile, instead of * silently contributing nothing the way the English-only extractor would. * * EXACT NUMBERS (see also the report to the caller of this work package) * ─────────────────────────────────────────────────────────────────────── * MAX_TURNS = 8 rolling window of prompts kept * MAX_PROFILE_TERMS = 64 cap on distinct terms in the decayed map * DECAY_BASE = 0.65 exponential decay per turn of distance * MAX_KEYWORDS_PER_TURN = 15 cap per single prompt (protects the * profile from one verbose prompt * dominating it) * MAX_QUERY_PROFILE_TERMS = 8 cap on profile terms merged into a query * STALE_MS = 24h a profile untouched this long is * discarded (treated as absent) on next * touch, not silently reused * SWEEP_STALE_MS = 7d sibling profile files older than this * are opportunistically deleted so state * files don't accumulate unboundedly */ export interface TopicTurn { /** Monotonic 1-based turn index; the most recently appended turn has the * highest value. Distance for decay purposes is computed relative to the * newest turn in the retained window, NOT wall-clock time. */ turn: number; keywords: string[]; } export interface TopicProfileFile { sessionKey: string; /** ISO timestamp of the last write — used for the 24h staleness check. */ updatedAt: string; /** Oldest-first, length capped at MAX_TURNS. */ turns: TopicTurn[]; } export declare function topicStateDir(root: string): string; export declare function topicStateFile(root: string, sessionKey: string): string; /** * Extract this turn's keywords for profile accumulation. Reuses `tokenize` * (Han-run segmentation + Latin stopword/length filtering) rather than the * hook's own English-only `extractKeywords`, so Chinese background chat * builds a topic profile too. Capped at MAX_KEYWORDS_PER_TURN so a single * long prompt cannot dominate the decayed map on its own. */ export declare function extractTopicKeywords(prompt: string): string[]; /** * Load a session's profile. A file whose `updatedAt` is more than STALE_MS * old is treated as absent AND deleted on this first touch (session hygiene * requirement — never silently reuse a stale background topic from a * different, long-past conversation). */ export declare function loadProfile(root: string, sessionKey: string): TopicProfileFile | null; /** * Compute the decayed term-weight map from a set of turns. Distance is * measured from the NEWEST turn in the array (distance 0), decaying by * DECAY_BASE per turn further back. A term recurring across multiple turns * accumulates weight from each occurrence — this is what lets a * persistently-mentioned background topic outrank a one-off word. */ export declare function computeDecayedProfile(turns: TopicTurn[]): Map; export declare function appendTurn(root: string, sessionKey: string, keywords: string[]): TopicProfileFile; /** * The top decayed profile terms NOT already present among this turn's own * (current-prompt) keywords, i.e. terms that are purely a background-topic * contribution. Shared by `topicQuery` (query construction) and the hook's * precision-tier gate (so both consume the exact same term set — a term * cannot count as "profile overlap" unless it was also a candidate the * recall query was actually built from). */ export declare function profileOnlyTerms(currentKeywords: string[], priorProfile: Map, limit?: number): string[]; /** * Merge current-prompt keywords (full weight — always included) with the * accumulated profile's decayed terms (bounded to MAX_QUERY_PROFILE_TERMS, * excluding anything already covered by the current prompt). Returns a * bounded keyword array suitable for building a recall query string. * * "Full weight" vs "decayed weight" here is not a literal per-term numeric * multiplier — smartRecall's query field is a plain string, so there is no * per-token weighting API to hook into (and smart-recall.ts is out of scope * for this change). The weighting is realized two ways instead: (1) current * keywords are unconditionally included while profile terms are capped and * ranked by decay, so current signal always dominates the query composition; * (2) the precision-tier gate (see the hook wiring) requires a STRICTLY * higher overlap bar for profile-only matches than for current-prompt * matches, so a profile term "counts less" toward triggering an injection. */ export declare function topicQuery(currentKeywords: string[], priorProfile: Map): string[]; export declare function sweepStaleProfiles(root: string, now?: number): number; //# sourceMappingURL=topic-state.d.ts.map