UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

206 lines (205 loc) • 7.62 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * Encoding-time salience scoring for issue #608. * * Pure module — no I/O. All inputs are pre-fetched by the caller. * Implements the three-signal model: novelty × 0.40 + magnitude × 0.35 + * predictionError × 0.25, clamped to [0, 1]. */ // ── Weights ──────────────────────────────────────────────────────────────────── const W_NOVELTY = 0.4; const W_MAGNITUDE = 0.35; const W_PREDICTION_ERROR = 0.25; // Guard: weights must sum to 1.0 (consistent with the pattern in salience.ts). // This check fires at module load and catches any future re-tuning mistakes. if (Math.abs(W_NOVELTY + W_MAGNITUDE + W_PREDICTION_ERROR - 1.0) > 1e-9) { throw new Error(`encoding-salience.ts: sub-signal weights must sum to 1.0 (got ${W_NOVELTY + W_MAGNITUDE + W_PREDICTION_ERROR})`); } // ── Novelty type floors ──────────────────────────────────────────────────────── const TYPE_NOVELTY_FLOOR = Object.freeze({ skill: 0.8, agent: 0.8, memory: 0.4, }); const DEFAULT_NOVELTY_FLOOR = 0.5; // ── Magnitude keyword sets ────────────────────────────────────────────────────── const SEVERITY_BUCKET = new Set(["error", "critical", "warning", "incident", "regression"]); // Note: "fails" and "urgent" are from the spec's initial keyword list and are included // here as strong constraint signals, even though the spec's final bucket table omits them. const CONSTRAINT_BUCKET = new Set(["must", "never", "always", "blocked", "breaking", "deprecated", "fails", "urgent"]); const ALL_MAGNITUDE_KEYWORDS = new Set([...SEVERITY_BUCKET, ...CONSTRAINT_BUCKET]); // Number of distinct keyword matches to reach full magnitude score. const MAGNITUDE_FULL_SCORE_THRESHOLD = 4; // Single-bucket magnitude cap: when only one semantic bucket matches. const MAGNITUDE_SINGLE_BUCKET_CAP = 0.5; // ── Stop-words (excluded from bigram tokenization) ──────────────────────────── const STOP_WORDS = new Set([ "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by", "from", "as", "is", "it", "its", "be", "are", "was", "were", "has", "have", "had", "do", "does", "did", "not", "this", "that", "these", "those", "i", "we", "you", "he", "she", "they", "my", "our", "your", "his", "her", "their", "can", "will", "would", "should", "could", "may", "might", "shall", "so", "if", "then", "than", "when", "while", "what", "which", "who", "how", "no", "any", "all", "more", "also", ]); // ── Tokenization helpers ────────────────────────────────────────────────────── /** * Tokenize text into lowercase words, splitting on whitespace and punctuation, * dropping stop-words. Used for body bigram extraction (novelty signal). */ function tokenizeBody(text) { return text .toLowerCase() .split(/[\s\p{P}\p{S}\-_/]+/u) .filter((t) => t.length > 1 && !STOP_WORDS.has(t)); } /** * Tokenize a ref name or tag into lowercase words without stop-word filtering. * Ref names are identifiers, not prose — all tokens are significant. */ function tokenizeRef(ref) { return ref .toLowerCase() .split(/[\s\p{P}\p{S}\-_/]+/u) .filter((t) => t.length > 0); } /** * Generate all consecutive bigrams from an array of tokens. * Returns them as "token1 token2" strings. */ function bigrams(tokens) { const result = []; for (let i = 0; i < tokens.length - 1; i++) { result.push(`${tokens[i]} ${tokens[i + 1]}`); } return result; } /** * Build the existing-ref vocabulary: a Set of bigrams derived from tokenizing * the provided ref names (and tags). Pass this to `scoreEncodingSalience` as * `existingRefVocabulary` so the novelty signal can measure how much of the * asset body is already represented in the stash vocabulary. */ export function buildRefVocabulary(refs) { const vocab = new Set(); for (const ref of refs) { const tokens = tokenizeRef(ref); for (const bg of bigrams(tokens)) { vocab.add(bg); } } return vocab; } // ── Sub-signal computations ──────────────────────────────────────────────────── function computeNovelty(body, type, vocab) { const tokens = tokenizeBody(body); const bgs = bigrams(tokens); const floor = TYPE_NOVELTY_FLOOR[type] ?? DEFAULT_NOVELTY_FLOOR; if (bgs.length === 0) return floor; const novelCount = bgs.filter((bg) => !vocab.has(bg)).length; const bigramNoveltyFraction = novelCount / bgs.length; return Math.max(floor, bigramNoveltyFraction); } function computeMagnitude(body) { const lowerBody = body.toLowerCase(); // Split on word boundaries to avoid partial matches (e.g. "errors" matching "error"). const words = new Set(lowerBody.split(/\W+/).filter((w) => w.length > 0)); const matched = [...ALL_MAGNITUDE_KEYWORDS].filter((kw) => words.has(kw)); if (matched.length === 0) return 0; const distinctCount = matched.length; const rawMagnitude = Math.min(1.0, distinctCount / MAGNITUDE_FULL_SCORE_THRESHOLD); // Require at least one match from each bucket to lift the single-bucket cap. const hasSeverity = matched.some((kw) => SEVERITY_BUCKET.has(kw)); const hasConstraint = matched.some((kw) => CONSTRAINT_BUCKET.has(kw)); if (hasSeverity && hasConstraint) return rawMagnitude; return Math.min(rawMagnitude, MAGNITUDE_SINGLE_BUCKET_CAP); } function computePredictionError(revisionCount) { if (revisionCount === 0) return 1.0; return 1 / (1 + Math.log(1 + revisionCount)); } // ── Main export ──────────────────────────────────────────────────────────────── /** * Compute the encoding-time salience score for an asset. * * Three sub-signals: * novelty (0.40) — how much of the body is absent from the stash vocabulary. * magnitude (0.35) — presence of severity/constraint keywords. * predictionError (0.25) — surprise: high for new assets, decays with revisions. */ export function scoreEncodingSalience(inputs) { const novelty = computeNovelty(inputs.body, inputs.type, inputs.existingRefVocabulary); const magnitude = computeMagnitude(inputs.body); const predictionError = computePredictionError(inputs.revisionCount); const raw = W_NOVELTY * novelty + W_MAGNITUDE * magnitude + W_PREDICTION_ERROR * predictionError; const score = Math.min(1, Math.max(0, raw)); return { score, novelty, magnitude, predictionError }; }