/** * Near-duplicate detection and evidence merge for role knowledge (#914 P1). * * `addRoleKnowledge` used to dedupe by exact match or substring containment * only, so the same fact written twice with a different id, count or date * in it ("Refresh standing answer … (1 newer)" / "… (6 newer)") became two * entries, and recall surfaced both. This module replaces that check with * a cheap nearest-neighbour over token sets: * * - `knowledgeTokens` lowercases, splits on non-alphanumerics and drops the * tokens that vary between restatements of one fact (uuids, hex ids, * long digit runs, single letters); * - `jaccard` is the set overlap of two token sets; * - `numbersCompatible` keeps two texts apart when they disagree on a * number (ticket, count, version): the frame may be identical, the fact * is not; * - `findNearestKnowledge` returns the best candidate in the same scope * (same category; overlapping tags when both sides have tags), an exact * or substring match first, else the highest Jaccard at or above the * threshold among number-compatible entries. Superseded and expired * entries are never candidates, so a new write cannot reinforce a dead * fact. Short texts (fewer than `MIN_TOKENS_FOR_NEAR` tokens) match * exactly or not at all. * - `mergeKnowledgeEvidence` folds the incoming entry's provenance into the * existing one (exact match): evidence union, importance max, * verification timestamp. The existing content stays. * - `restateKnowledge` handles a near match: the newest wording becomes a * new entry with the old provenance and reinforcement, the old entry is * marked superseded by it and kept for audit. The LLM adjudication the * issue describes (keep / modify / supersede by judgment) is a later, * possibly Pro, step; this is the deterministic rule. * - `evictionIndex` picks what a full store drops: superseded first. * * Pure functions only; the threshold was set by replaying the owner's * 3.7k real entries (see spec `2026-10-25-knowledge-evidence-merge.md`). * * @module services/memory/knowledge-similarity */ import type { RoleKnowledgeEntry } from '../../types/memory.types.js'; /** The fields the similarity check reads from an existing entry. */ export type KnowledgeLike = Pick; /** The fields the similarity check reads from an incoming entry. */ export type IncomingKnowledge = Pick & Partial>; /** How an existing entry matched the incoming one. */ export type MatchKind = 'exact' | 'near'; /** The nearest existing entry and how close it is. */ export interface NearestMatch { /** The existing entry */ entry: T; /** 1 for an exact or substring match, else the Jaccard overlap */ score: number; /** Exact (legacy rule) or near (token overlap at or above the threshold) */ kind: MatchKind; } /** Options for {@link findNearestKnowledge}. */ export interface NearestOptions { /** Jaccard at or above which a candidate is a near-duplicate; defaults to the constant */ threshold?: number; /** Reference instant for the expiry check; defaults to now */ now?: Date; } /** * Tokens of a knowledge text that survive a restatement: lowercase words * and numbers up to five digits, without uuids, hex ids, long digit runs or * single letters. Single digits stay: "entry 0" and "entry 1" are two * facts. * * @param text - Entry content * @returns Distinct tokens * * @example * ```typescript * knowledgeTokens('Claimed WorkItem X (id: 51851ddb-3e5f-48b4-b73a-fccbc6a79abb) at 2026-10-25, 3 newer') * // Set { 'claimed', 'workitem', 'id', 'at', '2026', '10', '25', '3', 'newer' } * ``` */ export declare function knowledgeTokens(text: string): Set; /** * Whether two token sets agree on their numbers: one side's numbers must * all appear on the other. "verify CE-140" and "verify CE-196" share every * word and differ in one number — two facts, however alike the frame. * "snapshot 10/13 03:40Z" and "snapshot 10/13" are one fact, the second * just shorter. Disagreeing counts ("1 newer" / "6 newer") are kept apart * on purpose: a false merge loses knowledge, a missed one only leaves a * duplicate. * * @param a - First token set * @param b - Second token set * @returns true when the numbers of one set are a subset of the other's */ export declare function numbersCompatible(a: Set, b: Set): boolean; /** * Jaccard overlap of two token sets: |A ∩ B| / |A ∪ B|. * * @param a - First set * @param b - Second set * @returns 0 when either set is empty, else the overlap in [0, 1] */ export declare function jaccard(a: Set, b: Set): number; /** * Whether an existing entry is in the incoming entry's scope: the same * category when the incoming one names a category, and at least one shared * tag when both carry tags. * * @param existing - Existing entry * @param incoming - Incoming entry * @returns true when the two may be compared */ export declare function sameScope(existing: Partial>, incoming: Partial>): boolean; /** * Equal after trim and lowercase. * * @param existing - Existing content * @param incoming - Incoming content * @returns true when the two texts are the same */ export declare function isSameText(existing: string, incoming: string): boolean; /** * One text contains the other, when the incoming text is longer than the * substring floor (the legacy containment rule). * * @param existing - Existing content * @param incoming - Incoming content * @returns true for a containment match */ export declare function containsText(existing: string, incoming: string): boolean; /** * The legacy rule: {@link isSameText} or {@link containsText}. * * @param existing - Existing content * @param incoming - Incoming content * @returns true for an exact or containment match */ export declare function isExactMatch(existing: string, incoming: string): boolean; /** * The existing entry the incoming one duplicates, if any: an exact match * first, else the highest token overlap at or above the threshold, among * entries in scope that recall still shows. * * @param knowledge - Existing entries * @param incoming - Entry about to be written * @param options - Threshold and reference time * @returns The nearest match, or null when nothing is close enough * * @example * ```typescript * const hit = findNearestKnowledge(memory.roleKnowledge, { content, category }); * if (hit) merge(hit.entry); else append(content); * ``` */ export declare function findNearestKnowledge(knowledge: T[], incoming: IncomingKnowledge, options?: NearestOptions): NearestMatch | null; /** What {@link mergeKnowledgeEvidence} changed on the existing entry. */ export interface MergeResult { /** Evidence refs added (not already present) */ addedEvidence: string[]; /** Whether importance was raised to the incoming value */ importanceRaised: boolean; } /** * Folds an incoming duplicate's provenance into the existing entry, in * place: evidence becomes the union (legacy `learnedFrom` and * `sourceTaskId` count as evidence), importance the max, and the entry is * marked verified and used now. Content, confidence and ids are left to the * caller. * * @param existing - Entry that stays (mutated) * @param incoming - Duplicate that is not written * @param now - Timestamp for `lastVerifiedAt` / `lastUsed` * @returns What changed */ export declare function mergeKnowledgeEvidence(existing: RoleKnowledgeEntry, incoming: Partial>, now?: Date): MergeResult; /** Prefix of the evidence ref a superseded entry gets when a restatement replaced it. */ export declare const RESTATEMENT_REASON_PREFIX = "supersession-reason:restated"; /** * A near-duplicate is the same fact said again, often with a correction * ("I asserted X is a working symlink" → "X is a DANGLING symlink"), so the * newest wording wins: the incoming entry becomes a new entry carrying the * old one's provenance and reinforcement, and the old entry is marked * superseded by it — hidden from recall, kept for audit, the same way * `MemorySupersessionService.supersede` records an explicit supersession. * * @param old - Existing near-duplicate (mutated: superseded, evidence reason appended) * @param incoming - Entry being written * @param ids - The new entry's id and timestamp, and the match score for the audit ref * @returns The new entry to store * * @example * ```typescript * const fresh = restateKnowledge(match.entry, entry, { id: uuidv4(), now: new Date(), score: match.score }); * memory.roleKnowledge.push(fresh); * ``` */ export declare function restateKnowledge(old: RoleKnowledgeEntry, incoming: Omit, ids: { id: string; now: Date; score: number; }): RoleKnowledgeEntry; /** * Which entry to evict when a store is full: the oldest superseded entry * first (recall no longer shows it), else the lowest-confidence live one. * * @param knowledge - Entries * @returns Index to remove, or -1 for an empty list */ export declare function evictionIndex(knowledge: RoleKnowledgeEntry[]): number; //# sourceMappingURL=knowledge-similarity.d.ts.map