/** * BRAIN-powered tiered duplicate-task detection for `cleo add`. * * Before a new task is inserted, this module queries active tasks and computes * similarity between the incoming title+description and each active task. * * Three-tier escalation (T1681): * * Tier 1 — lexical OR vector similarity. NOT BM25, despite the historical * naming: there is no IDF, no term frequency and no length normalisation. * Two implementations, selected at runtime (see `runDuplicateCheck`): * - lexical: Jaccard over CHARACTER TRIGRAMS of a title-2x-weighted blob * - vector: cosine over embeddings, when an embedding provider is loaded * score >= 0.92 → reject (clear match) * score < 0.50 → insert (clear different) * score in [0.50, 0.92) → escalate to Tier 2 * * Tier 2 — Jaccard on word-level n-grams (title+description+labels): * score >= 0.85 → reject * score < 0.40 → insert * score in [0.40, 0.85) → escalate to Tier 3 (LLM, paid) * * Tier 3 — one batched System One decision (T12492), then the optional * generative LLM tier: * - System One: ONE `decide()` request carrying up to {@link MAX_CANDIDATES} * noul questions ("is the new task the same work as candidate cN?"), * bounded by {@link DUPLICATE_DECISION_BUDGET_MS} end to end. Mode comes * from `decide.sites.duplicateDetection` (`off | shadow | on`): `shadow` * (the default once a provider is configured) records the heuristic and * decision answers side by side in `.cleo/audit/decisions.jsonl` and acts * on the heuristic; `on` acts on the decision when one arrives. No * provider configured → `off`, and no network call is made at all. * - Generative LLM (T1681, max 1 call): OPT-IN via * `decide.generativeFallback.duplicateDetection: true`. Off by default, * so `cleo add` no longer pays its 15 s timeout. * Fallback on every failure: the Tier-1-only decision (never block on error). * * Thresholds (original T1633 behavior preserved for the Tier-1 clear-match path): * Tier-1 score >= 0.85 → warning emitted to stderr (non-blocking) * Tier-1 score >= 0.92 → rejected with E_DUPLICATE_TASK_LIKELY * * @epic T1627 * @task T1633 * @task T1681 */ import type { DecisionRequest, Task } from '@cleocode/contracts'; import { z } from 'zod'; import type { DecideOptions } from '../decide/client.js'; import { type DecisionSiteMode } from '../decide/site.js'; import type { DataAccessor } from '../store/data-accessor.js'; /** Tier-1 score at which a non-blocking warning is emitted. (Name kept: exported API.) */ export declare const DUPLICATE_WARN_THRESHOLD = 0.85; /** Tier-1 score at which creation is rejected (clear match). (Name kept: exported API.) */ export declare const DUPLICATE_REJECT_THRESHOLD = 0.92; /** Maximum number of candidates to surface in the warning/rejection message. */ export declare const MAX_CANDIDATES = 3; /** * End-to-end budget for the System One duplicate decision, in milliseconds: * module load, settings, redaction, the shared request budget and the HTTP * round trip (connection setup included) all fit inside it. * * @task T12492 */ export declare const DUPLICATE_DECISION_BUDGET_MS = 300; /** * Minimum confidence EVERY decision answer needs before `on` mode acts on the * decision. Below it the decision is audited but the heuristic acts. */ export declare const DUPLICATE_DECISION_MIN_CONFIDENCE = 0.6; /** Call-site id for the duplicate decision; keys the audit line. */ export declare const DUPLICATE_DECISION_SITE: "tasks.duplicate-detection"; /** Config key selecting the System One mode for duplicate detection. */ export declare const DUPLICATE_DECISION_MODE_KEY: "decide.sites.duplicateDetection"; /** Config key opting in to the generative LLM tier (T1681). Default `false`. */ export declare const DUPLICATE_LLM_TIER_KEY: "decide.generativeFallback.duplicateDetection"; /** A candidate active task that scored above the warning threshold. */ export interface DuplicateCandidate { /** The matching active task ID. */ id: string; /** The matching active task title. */ title: string; /** Similarity score in [0, 1]. */ score: number; } /** Result returned by {@link checkDuplicates}. */ export interface DuplicateCheckResult { /** * Maximum similarity score across all active tasks. * 0 when no active tasks are found or all scores are below warning threshold. */ maxScore: number; /** Top-N candidates sorted by score descending (above warning threshold only). */ candidates: DuplicateCandidate[]; /** Whether any candidate exceeds the reject threshold. */ shouldReject: boolean; /** Whether any candidate exceeds the warn threshold (but not the reject threshold). */ shouldWarn: boolean; /** * Which tier produced the final decision. * @task T1681 */ /** * Which tier produced the decision. * * `'bm25'` is a HISTORICAL label for the Tier-1 measure, kept because it is * emitted in the envelope and renaming it would break consumers. Tier 1 is * not BM25 — see the module docblock. Reporting WHICH Tier-1 implementation * ran (lexical vs vector) is tracked separately. */ tier?: 'bm25' | 'jaccard' | 'llm' | 'decision'; } /** * System One mode for duplicate detection (T12492). * * - `off` — no decision request. * - `shadow` — ask, audit both answers, act on the heuristic. * - `on` — ask, act on the decision; the heuristic acts on any fallback. */ export type DuplicateDecisionMode = DecisionSiteMode; /** Optional wiring for {@link checkDuplicates}. Every field defaults from config. */ export interface DuplicateCheckOptions { /** Force a mode instead of reading {@link DUPLICATE_DECISION_MODE_KEY}. Still `off` when unconfigured. */ mode?: DuplicateDecisionMode; /** Force the generative LLM tier on/off instead of reading {@link DUPLICATE_LLM_TIER_KEY}. */ llmTier?: boolean; /** Provider, connection, budget, cache and audit wiring forwarded to `decide()`. Tests inject a stub here. */ decide?: DecideOptions; } /** * Structured output schema for the LLM duplicate-reasoning call. * @task T1681 */ export declare const DuplicateReasoningSchema: z.ZodObject<{ are_duplicate: z.ZodBoolean; confidence: z.ZodNumber; distinction: z.ZodNullable; suggestion: z.ZodEnum<{ merge: "merge"; "keep-both": "keep-both"; "block-new": "block-new"; }>; }, z.core.$strip>; /** Inferred type for the LLM reasoning result. */ export type DuplicateReasoning = z.infer; /** * Compute lexical similarity between two (title, description) pairs. * * Uses Jaccard similarity over character trigrams of a weighted blob * (title 2×, description 1×). This is zero-dependency, deterministic, * and symmetrical. This is the LEXICAL Tier-1 measure — it is not BM25. * * @param titleA - First title. * @param descA - First description. * @param titleB - Second title. * @param descB - Second description. * @returns Similarity score in [0, 1]. */ export declare function computeLexicalSimilarity(titleA: string, descA: string, titleB: string, descB: string): number; /** * Compute Jaccard similarity over word-level n-grams including labels. * Used as Tier-2 discriminator when the Tier-1 score is ambiguous. * * @param titleA - First title. * @param descA - First description. * @param labelsA - First task labels. * @param titleB - Second title. * @param descB - Second description. * @param labelsB - Second task labels. * @returns Jaccard score in [0, 1]. */ export declare function computeJaccardWordSimilarity(titleA: string, descA: string, labelsA: string[], titleB: string, descB: string, labelsB: string[]): number; /** * Call the daemon LLM to determine whether two tasks are semantic duplicates. * * Cost cap: max 1 call per `cleo add` invocation (enforced by the caller via * a `llmCallMade` flag). Never throws — returns null on error/timeout so the * caller can fall back to a Tier-1-only decision. * * @param incomingTitle - Title of the task being added. * @param incomingDescription - Description of the task being added. * @param candidate - Best-scoring candidate from Tier 1/2. * @param cwd - Project root for credential + config resolution. * @returns Structured reasoning result, or null when the call fails/times out. * @task T1681 */ export declare function callLlmDuplicateReasoning(incomingTitle: string, incomingDescription: string, candidate: DuplicateCandidate & { description?: string; }, cwd?: string): Promise; /** One Tier-2-ambiguous candidate offered to the decision. */ /** The part of a candidate the duplicate decision sends: its id, title and description. */ export interface DuplicateDecisionCandidateText { /** The candidate task (only `id`, `title` and `description` are read). */ readonly task: Pick; } /** * Build ONE decision request: the new task plus up to {@link MAX_CANDIDATES} * candidates as structured state, and one noul question per candidate * (`c1`, `c2`, …). Exported for the T12495 benchmark, which replays the exact * question the site asks. * * @param title - New task title. * @param description - New task description. * @param candidates - Candidate tasks (the Tier-1 score is not sent). * @param redact - Redaction applied to every text field BEFORE clipping. * @returns The request. */ export declare function buildDuplicateDecisionRequest(title: string, description: string, candidates: readonly DuplicateDecisionCandidateText[], redact: (s: string) => string): DecisionRequest; /** * Check whether the incoming task's title and description are similar to any * active task in the database using three-tier escalation (T1681). * * Algorithm: * * Tier 1 — lexical or vector similarity (per candidate): * For each active task, compute vector cosine similarity (if embedding is available) * Jaccard over character trigrams, or cosine over embeddings when a provider is loaded. * - score >= DUPLICATE_REJECT_THRESHOLD (0.92): clear match → reject immediately. * - score < TIER1_ESCALATE_LOW (0.50): clear different → skip Tier 2 + 3 for this candidate. * - score in [0.50, 0.92): ambiguous → collect for Tier-2 Jaccard evaluation. * * Tier 2 — Jaccard word n-grams (title+description+labels): * For candidates that were ambiguous in Tier 1, compute Jaccard over word-level n-grams. * Labels are included to surface tag-level similarity that character trigrams miss. * - score >= JACCARD_REJECT_THRESHOLD (0.85): reject. * - score < JACCARD_ESCALATE_LOW (0.40): clear different → skip LLM. * - score in [0.40, 0.85): ambiguous → escalate to LLM (cost cap: 1 call per invocation). * * Tier 3 — System One, then the opt-in generative LLM (T12492): * One `decide()` request with up to 3 candidates as noul questions, bounded by * DUPLICATE_DECISION_BUDGET_MS. `shadow` audits it and acts on the heuristic; `on` * acts on it. Then, only when `decide.generativeFallback.duplicateDetection` is true, * the T1681 LLM call (max 1). On any failure: the Tier-1 decision (never block). * * @param title - Title of the task being added. * @param description - Description of the task being added (empty string if not provided). * @param accessor - DataAccessor instance to load active tasks from. * @param labels - Labels of the task being added (empty array if not provided). * @param cwd - Project root for LLM credential resolution (Tier 3). * @param options - System One mode, generative-tier opt-in and `decide()` wiring (T12492). * @returns Duplicate check result with tier provenance. * @task T1633 * @task T1681 * @task T12492 */ export declare function checkDuplicates(title: string, description: string, accessor: DataAccessor, labels?: string[], cwd?: string, options?: DuplicateCheckOptions): Promise; /** * Format a human-readable candidate list for warning/rejection messages. * * @param candidates - Array of duplicate candidates. * @param label - What `score` measures; defaults to the lexical `similarity`. * @returns Formatted multi-line string listing candidates. */ export declare function formatCandidateList(candidates: DuplicateCandidate[], label?: string): string; /** * Build a warning message for candidates above the warn threshold. * * @param candidates - Candidates to include in the message. * @returns Warning string (no newline at end). */ export declare function buildWarnMessage(candidates: DuplicateCandidate[]): string; /** * Build a rejection message for candidates above the reject threshold. * * A System One rejection (`tier: 'decision'`) is worded as what it is — the * model's duplicate probability — never as a lexical similarity score. * * @param candidates - Candidates to include in the message. * @param tier - The tier that decided (from {@link DuplicateCheckResult.tier}). * @returns Rejection string (no newline at end). */ export declare function buildRejectMessage(candidates: DuplicateCandidate[], tier?: DuplicateCheckResult['tier']): string; /** * Load active tasks from the data accessor, restricted to non-terminal statuses. * Exported for testing purposes. * * @param accessor - DataAccessor to query. * @returns Array of non-terminal tasks (pending, active, blocked). */ export declare function loadActiveTasks(accessor: DataAccessor): Promise; /** Env var overriding {@link DUPLICATE_CHECK_BUDGET_MS}. */ export declare const DUPLICATE_CHECK_BUDGET_ENV = "CLEO_DUPLICATE_CHECK_BUDGET_MS"; /** * Resolve the duplicate-detection budget, honouring an operator override. * * @param env - environment to read the override from. * @returns budget in ms; the default when unset or malformed. */ export declare function resolveDuplicateCheckBudgetMs(env?: NodeJS.ProcessEnv): number; /** * {@link checkDuplicates} with a hard deadline and a fail-OPEN result. * * ## Why fail open (T12117 · issue #1244) * * Duplicate detection runs before the insert, so anything that stalls it * doesn't slow the write down — it *loses* it. Reported from the field: three * `cleo add` attempts at 280 s each, none of which created a row, on a host * under memory pressure. No error, no id, no task. A silent write failure is * the worst failure mode a task tracker has, because the agent that filed the * task believes the work is recorded. * * It is also silent in the dangerous direction relative to CLEO's documented * recovery advice. A killed mutation is supposed to mean "query before * retrying — the write has usually landed", which was independently confirmed * true for a timeout-killed write. But these writes had NOT landed, and nothing * in the output distinguishes the two cases. An agent following the contract * correctly reaches the wrong conclusion in one direction or the other. * * So: the row is the product, detection is enrichment. When the budget is * blown, report that on stderr and let the insert proceed. A duplicate that * slips through is recoverable by a human in seconds; a task that was never * created is lost work nobody knows to look for. * * @param args - forwarded verbatim to {@link checkDuplicates}. * @returns the real verdict, or a permissive one when the budget was exceeded. * * @task T12117 */ export declare function checkDuplicatesBounded(...args: Parameters): Promise; //# sourceMappingURL=duplicate-detector.d.ts.map