akm-cli 0.9.1-beta.2 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.1] - 2026-08-18
10
+
11
+ ### Fixed
12
+
13
+ - Preserve multiline frontmatter descriptions when `akm lint --fix` quotes
14
+ colons, recover already-malformed quoted descriptions, and report a fix only
15
+ when the file actually changes.
16
+ - Skip consolidation promotion proposals whose body already exists in a live
17
+ knowledge asset, preventing exact-content duplicates from recurring in the
18
+ proposal backlog.
19
+ - Derive utility `last_used_at` values only from real user retrieval events
20
+ (`search`, `show`, and `curate`) instead of stamping assets with index time.
21
+ - Compute the salience-distribution health metric over every positive,
22
+ non-missing salience value rather than a top-ranked 100-row slice, and report
23
+ the evaluated sample size.
24
+
9
25
  ## [0.9.1-beta.2] - 2026-08-17
10
26
 
11
27
  ### Breaking changes & migration
@@ -53,17 +53,17 @@ export function collectImproveAdvisories(db, stateDbPath, since, improveSummary)
53
53
  "treat outcome-derived rank contributions as noise until a real usage/outcome signal lands.",
54
54
  });
55
55
  }
56
- // Salience-distribution collapse: Gini below the uniform baseline means
57
- // ranking no longer discriminates between assets.
56
+ // Salience-distribution collapse across all assets with retrieval evidence.
58
57
  if (improveSummary.degradation?.salienceUniformityFlagged) {
58
+ const sampleSize = improveSummary.degradation.retrievalSalienceSampleSize;
59
59
  advisories.push({
60
60
  name: "salience-uniformity-collapse",
61
61
  status: "warn",
62
62
  kind: "deterministic",
63
63
  confidence: "high",
64
- message: `Salience distribution collapsed toward uniform: top-100 retrieval_salience Gini = ` +
65
- `${improveSummary.degradation.corpusCentroidDistance} < 0.08 (uniform baseline ≈ 0.1). ` +
66
- "Ranking currently carries little to no discrimination between assets.",
64
+ message: `Salience distribution collapsed toward uniform: retrieval_salience Gini = ` +
65
+ `${improveSummary.degradation.corpusCentroidDistance} < 0.08 across ${sampleSize} ` +
66
+ "observed, resolvable assets. Ranking carries little discrimination among assets with retrieval evidence.",
67
67
  });
68
68
  }
69
69
  // Enrichment-vs-minting policy: enrichment lanes edit existing assets;
@@ -351,8 +351,8 @@ function renderActionItems(vm) {
351
351
  prio: "P2",
352
352
  cls: "warn",
353
353
  title: "Salience distribution collapsed: retrieval_salience Gini < 0.08",
354
- descHtml: "The top-100 salience scores are near-uniform (uniform baseline ≈ 0.1) " +
355
- "ranking currently carries little to no discrimination between assets. " +
354
+ descHtml: `The ${vm.degradation.retrievalSalienceSampleSize} observed, resolvable salience scores are near-uniform — ` +
355
+ "ranking carries little discrimination among assets with retrieval evidence. " +
356
356
  `Corpus diversity proxy: ${esc(String(vm.degradation.corpusCentroidDistance))}.`,
357
357
  remedy: "akm health --format json | jq '.improve.degradation'",
358
358
  });
@@ -12,11 +12,23 @@ import { withStateDb } from "../../core/state-db.js";
12
12
  import { insertEvent } from "../../storage/repositories/events-repository.js";
13
13
  import { queryImproveRuns } from "../../storage/repositories/improve-runs-repository.js";
14
14
  import { listStateProposals } from "../../storage/repositories/proposals-repository.js";
15
- import { getTopRetrievalSalience } from "../../storage/repositories/salience-repository.js";
15
+ import { getObservedRetrievalSalience } from "../../storage/repositories/salience-repository.js";
16
16
  import { roundRate, toFiniteNumber } from "./improve-metrics.js";
17
17
  import { ENRICHMENT_LANES, } from "./types.js";
18
18
  /** Event type appended + read back by the state.db round-trip probe. */
19
19
  const HEALTH_PROBE_EVENT = "health_probe";
20
+ /**
21
+ * Retrieval-salience Gini guardrails.
22
+ *
23
+ * These are distribution-shape thresholds, not quantiles tied to a corpus
24
+ * size. Synthetic anchors pinned in monitor-liveness.test.ts are ~0.01 for a
25
+ * near-uniform two-band distribution, 0.25 for a balanced 0.25/0.75 spread,
26
+ * and ~0.82 for one dominant value among nine 0.01 values. Full-observation
27
+ * production snapshots also sit stably in the neutral band: 0.2613 at n=1,209
28
+ * (2026-07-12) and 0.2506 at n=1,454 (2026-08-17, missing rows excluded).
29
+ */
30
+ const SALIENCE_UNIFORMITY_GINI_THRESHOLD = 0.08;
31
+ const SALIENCE_ENTRENCHMENT_GINI_THRESHOLD = 0.35;
20
32
  /** Synthetic sentinel ref (ref-grammar decision D-R3): a colon-free
21
33
  * `<subsystem>/_<marker>` label. `health` has no asset stash-subdir, so
22
34
  * `health/_probe` names the subsystem. */
@@ -204,38 +216,41 @@ export function computeEnrichmentMintingRollup(db, since, until) {
204
216
  * @param until - Window end (ISO-8601).
205
217
  */
206
218
  export function computeDegradationMetrics(db, since, until) {
207
- // (a) Corpus diversity — salience rank distribution of the top-100 assets.
208
- // We use the Gini coefficient of retrieval_salience scores as an intra-corpus
209
- // diversity proxy. A Gini close to 1 = highly concentrated (entrenched top
210
- // assets), Gini near 0 = flat/diverse. This is a single-snapshot metric;
211
- // consecutive-run centroid distance requires cross-run history not yet stored.
219
+ // (a) Corpus diversity — distribution of every observed retrieval-salience
220
+ // value for a currently resolvable asset. Zero is the no-observation floor
221
+ // and is excluded: the diagnostic measures discrimination among assets that
222
+ // have a retrieval signal, not corpus coverage.
223
+ //
224
+ // Do not preselect by rank_score here. The old top-100 sample truncated the
225
+ // distribution before measuring it, producing a low Gini even when the full
226
+ // observed corpus had a healthy spread.
212
227
  let corpusCentroidDistance = Number.NaN;
228
+ let retrievalSalienceSampleSize = 0;
213
229
  let entrenchmentFlagged;
214
230
  let salienceUniformityFlagged;
215
231
  try {
216
232
  // Fail-open: the asset_salience table may not exist yet (pre-WS-1 install).
217
- // getTopRetrievalSalience (storage/repositories/salience-repository.ts,
218
- // #672 part 2) owns the raw SQL; this call site's try/catch is unchanged.
219
- const rows = getTopRetrievalSalience(db, 100);
233
+ const rows = getObservedRetrievalSalience(db);
234
+ retrievalSalienceSampleSize = rows.length;
220
235
  if (rows.length >= 5) {
236
+ // The repository returns ascending values. Keep a defensive sort so the
237
+ // O(n log n) closed-form Gini remains correct if that contract changes.
221
238
  const vals = rows.map((r) => r.retrieval_salience).sort((a, b) => a - b);
222
239
  const n = vals.length;
223
- const sumAbsDiff = vals.reduce((acc, xi, i) => {
224
- return acc + vals.slice(i + 1).reduce((a, xj) => a + Math.abs(xi - xj), 0);
225
- }, 0);
226
- const mean = vals.reduce((a, b) => a + b, 0) / n;
227
- // `sumAbsDiff` contains each unordered pair once, so the standard Gini
228
- // denominator is × mean (the equivalent ordered-pair formula has 2n²).
229
- const gini = mean > 0 ? sumAbsDiff / (n * n * mean) : 0;
240
+ const sum = vals.reduce((acc, value) => acc + value, 0);
241
+ const weightedSum = vals.reduce((acc, value, index) => acc + (index + 1) * value, 0);
242
+ // Closed-form Gini for sorted non-negative values. This is O(n log n)
243
+ // including the defensive sort; the previous pairwise implementation
244
+ // was O(n²) and only safe because the sample was capped at 100.
245
+ const gini = sum > 0 ? (2 * weightedSum) / (n * sum) - (n + 1) / n : 0;
230
246
  // Re-express as a diversity proxy in [0,1]: high gini = low diversity.
231
247
  // corpusCentroidDistance approximation: gini is "distance from uniform".
232
- // Two-tailed: >0.35 flags entrenchment (robustly above the ~0.1 uniform
233
- // baseline); <0.08 flags uniformity collapse the distribution no longer
234
- // discriminates between assets (live 2026-07 value 0.040 sat unflagged
235
- // in this tail under the old one-tailed check).
248
+ // Two-tailed: high concentration flags entrenchment; very low spread
249
+ // flags near-uniformity. Calibration provenance is documented with the
250
+ // constants above and pinned by synthetic distribution tests.
236
251
  corpusCentroidDistance = roundRate(gini);
237
- entrenchmentFlagged = gini > 0.35;
238
- salienceUniformityFlagged = gini < 0.08;
252
+ entrenchmentFlagged = gini > SALIENCE_ENTRENCHMENT_GINI_THRESHOLD;
253
+ salienceUniformityFlagged = gini < SALIENCE_UNIFORMITY_GINI_THRESHOLD;
239
254
  }
240
255
  }
241
256
  catch {
@@ -305,6 +320,7 @@ export function computeDegradationMetrics(db, since, until) {
305
320
  }
306
321
  return {
307
322
  corpusCentroidDistance,
323
+ retrievalSalienceSampleSize,
308
324
  entrenchmentFlagged,
309
325
  salienceUniformityFlagged,
310
326
  mergeFidelityContradictionRate,
@@ -455,7 +455,7 @@ function buildSummaryRows(aggregates, trend) {
455
455
  "Corpus diversity (Gini)",
456
456
  num(degradation.corpusCentroidDistance),
457
457
  degradation.entrenchmentFlagged || degradation.salienceUniformityFlagged ? "down" : "flat",
458
- "Gini coefficient of retrieval_salience for top-100 ranked assets. Two-tailed: >0.35 = entrenchment risk; <0.08 = collapsed toward uniform (ranking no longer discriminates).",
458
+ `Gini coefficient of positive retrieval_salience values across ${degradation.retrievalSalienceSampleSize} resolvable assets. Two-tailed: >0.35 = entrenchment risk; <0.08 = collapsed toward uniform.`,
459
459
  ], [
460
460
  "Merge fidelity contradiction rate",
461
461
  pct(degradation.mergeFidelityContradictionRate, 1),
@@ -286,6 +286,46 @@ function loadPendingConsolidateProposalHashes(stashDir) {
286
286
  }
287
287
  return hashes;
288
288
  }
289
+ /**
290
+ * Hash the bodies of live knowledge assets once per consolidation run.
291
+ *
292
+ * Pending-proposal dedup prevents repeated queue entries, but accepted
293
+ * proposals leave that set. Without a live-asset guard, the next run can copy
294
+ * the same memory body into a new knowledge slug indefinitely. Scan the target
295
+ * tree directly (rather than trusting the asynchronously refreshed index) so
296
+ * an already-written asset suppresses recurrence immediately.
297
+ */
298
+ export function loadExistingKnowledgeBodyHashes(targetRoot) {
299
+ const hashes = new Set();
300
+ const knowledgeRoot = path.join(targetRoot, "knowledge");
301
+ if (!fs.existsSync(knowledgeRoot))
302
+ return hashes;
303
+ const visit = (dir) => {
304
+ let entries;
305
+ try {
306
+ entries = fs.readdirSync(dir, { withFileTypes: true });
307
+ }
308
+ catch {
309
+ return;
310
+ }
311
+ for (const entry of entries) {
312
+ const entryPath = path.join(dir, entry.name);
313
+ if (entry.isDirectory()) {
314
+ visit(entryPath);
315
+ }
316
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
317
+ try {
318
+ hashes.add(cacheHash(fs.readFileSync(entryPath, "utf8")));
319
+ }
320
+ catch {
321
+ // An unreadable asset cannot provide reliable duplicate evidence.
322
+ }
323
+ }
324
+ }
325
+ };
326
+ visit(knowledgeRoot);
327
+ return hashes;
328
+ }
289
329
  /** Parse a stored provenance ref and emit its canonical D-R5 display spelling. */
290
330
  function canonicalStoredXref(ref) {
291
331
  try {
@@ -976,6 +1016,7 @@ async function akmConsolidateInner(opts, config, stashDir, startMs, warnings, sh
976
1016
  memoryByRef,
977
1017
  promoted,
978
1018
  promotedSourceRefs: new Set(),
1019
+ existingKnowledgeBodyHashes: loadExistingKnowledgeBodyHashes(opts.writeTarget.source.path),
979
1020
  promotionFailures,
980
1021
  warnings,
981
1022
  pushSkipReason: accounting.pushSkipReason,
@@ -1011,8 +1052,26 @@ async function akmConsolidateInner(opts, config, stashDir, startMs, warnings, sh
1011
1052
  },
1012
1053
  });
1013
1054
  }
1055
+ /** Reject a promotion when its body already exists in knowledge or the queue. */
1056
+ function shouldSkipPromotionBodyDuplicate(args) {
1057
+ const { bodyHash, op, knowledgeRef, ctx } = args;
1058
+ if (ctx.existingKnowledgeBodyHashes.has(bodyHash)) {
1059
+ ctx.warnings.push(`Skipping promote: identical body already exists in knowledge; skipping duplicate for ${op.ref} → ${knowledgeRef}`);
1060
+ ctx.pushSkipReason("promote", op.ref, "dedup_existing_knowledge");
1061
+ return true;
1062
+ }
1063
+ const contentDupProposal = listProposals(ctx.stashDir, { status: "pending" })
1064
+ .filter((proposal) => proposal.source === "consolidate")
1065
+ .find((proposal) => cacheHash(proposalContent(proposal)) === bodyHash);
1066
+ if (!contentDupProposal)
1067
+ return false;
1068
+ ctx.warnings.push(`Skipping promote: identical body already pending as proposal ${contentDupProposal.id} (ref: ${contentDupProposal.ref}); skipping duplicate for ${op.ref} → ${knowledgeRef}`);
1069
+ ctx.pushSkipReason("promote", op.ref, "dedup_pending_proposal");
1070
+ return true;
1071
+ }
1014
1072
  /** Execute one reconciled promotion by emitting a reviewable proposal. */
1015
- async function emitPromotionProposal(op, ctx) {
1073
+ /** @internal Executes the real proposal-emission path for one promote operation. */
1074
+ export async function emitPromotionProposal(op, ctx) {
1016
1075
  const { config, stashDir, sourceRun, target, memoryByRef, warnings, pushSkipReason, promoted, promotedSourceRefs } = ctx;
1017
1076
  const entry = memoryByRef.get(op.ref);
1018
1077
  if (!entry) {
@@ -1111,15 +1170,8 @@ async function emitPromotionProposal(op, ctx) {
1111
1170
  // Use cacheHash (case-preserving stripped body) to match the canonical
1112
1171
  // hash domain used by the body-embedding cache and pending-proposal set.
1113
1172
  const bodyHash = cacheHash(sourceBody);
1114
- const allPendingConsolidateProposals = listProposals(stashDir, { status: "pending" }).filter((p) => p.source === "consolidate");
1115
- const contentDupProposal = allPendingConsolidateProposals.find((p) => {
1116
- return cacheHash(proposalContent(p)) === bodyHash;
1117
- });
1118
- if (contentDupProposal) {
1119
- warnings.push(`Skipping promote: identical body already pending as proposal ${contentDupProposal.id} (ref: ${contentDupProposal.ref}); skipping duplicate for ${op.ref} → ${knowledgeRef}`);
1120
- pushSkipReason("promote", op.ref, "dedup_pending_proposal");
1173
+ if (shouldSkipPromotionBodyDuplicate({ bodyHash, op, knowledgeRef, ctx }))
1121
1174
  return;
1122
- }
1123
1175
  try {
1124
1176
  // Use LLM-provided description; fall back to memory's own description
1125
1177
  // (post-sanitization frontmatter is authoritative).
@@ -33,6 +33,7 @@
33
33
  // ----------------------------------------------------------------------------
34
34
  import fs from "node:fs";
35
35
  import path from "node:path";
36
+ import { isScalar, parseDocument } from "yaml";
36
37
  import { assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
37
38
  import { BUNDLE_REF_RE } from "../../core/asset/asset-ref.js";
38
39
  import { spliceFrontmatterLine } from "../../core/asset/frontmatter.js";
@@ -41,26 +42,87 @@ import { typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
41
42
  import { localDateStamp } from "../../core/common.js";
42
43
  import { findFenceRegions } from "./markdown-insertion.js";
43
44
  // ── Helpers ───────────────────────────────────────────────────────────────────
45
+ /** Fold physically wrapped prose the same way a YAML plain scalar does. */
46
+ function foldDescriptionLines(lines) {
47
+ let value = "";
48
+ let blankLines = 0;
49
+ for (const line of lines) {
50
+ const trimmed = line.trim();
51
+ if (!trimmed) {
52
+ blankLines++;
53
+ continue;
54
+ }
55
+ if (value)
56
+ value += blankLines > 0 ? "\n".repeat(blankLines) : " ";
57
+ value += trimmed;
58
+ blankLines = 0;
59
+ }
60
+ return value;
61
+ }
62
+ /**
63
+ * Recover a description from malformed wrapped YAML.
64
+ *
65
+ * Older producers emitted a valid quoted first physical line followed by
66
+ * indented prose outside the quote. The full document cannot be parsed, but
67
+ * the first line can still be decoded independently and the continuation can
68
+ * be folded without guessing at escape sequences in that first segment.
69
+ */
70
+ function recoverMalformedDescription(firstSegment, continuation) {
71
+ const firstLine = parseDocument(`description: ${firstSegment}`);
72
+ const firstValue = firstLine.get("description", true);
73
+ const decodedFirst = firstLine.errors.length === 0 && isScalar(firstValue) && typeof firstValue.value === "string"
74
+ ? firstValue.value
75
+ : firstSegment.trim();
76
+ const value = foldDescriptionLines([decodedFirst, ...continuation]);
77
+ return value || null;
78
+ }
79
+ /**
80
+ * Quote the complete description scalar, including physical continuation
81
+ * lines, and verify that the replacement is valid YAML before returning it.
82
+ */
44
83
  function fixUnquotedColon(raw) {
84
+ const eol = raw.includes("\r\n") ? "\r\n" : "\n";
45
85
  const lines = raw.split(/\r?\n/);
46
86
  if (lines[0]?.trim() !== "---")
47
- return raw;
87
+ return null;
48
88
  const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
49
89
  if (closeIdx === -1)
50
- return raw;
90
+ return null;
51
91
  for (let i = 1; i < closeIdx; i++) {
52
- const m = lines[i].match(/^(description:\s*)(.*)/);
92
+ const m = lines[i]?.match(/^(description:\s*)(.*)/);
53
93
  if (!m)
54
94
  continue;
55
- const prefix = m[1];
56
- const value = m[2].trim();
57
- if ((value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
58
- (value.startsWith("'") && value.endsWith("'") && value.length >= 2))
95
+ const [, prefix, firstSegment] = m;
96
+ if (prefix === undefined || firstSegment === undefined)
59
97
  continue;
60
- lines[i] = `${prefix}"${value.replace(/"/g, '\\"')}"`;
61
- break;
98
+ let continuationEnd = i + 1;
99
+ while (continuationEnd < closeIdx) {
100
+ const continuationLine = lines[continuationEnd];
101
+ if (continuationLine === undefined || (!/^[ \t]/.test(continuationLine) && continuationLine.trim()))
102
+ break;
103
+ continuationEnd++;
104
+ }
105
+ const frontmatter = lines.slice(1, closeIdx).join("\n");
106
+ const document = parseDocument(frontmatter);
107
+ const description = document.get("description", true);
108
+ const value = document.errors.length === 0 && isScalar(description) && typeof description.value === "string"
109
+ ? description.value
110
+ : recoverMalformedDescription(firstSegment.trim(), lines.slice(i + 1, continuationEnd));
111
+ if (value === null)
112
+ return null;
113
+ lines.splice(i, continuationEnd - i, `${prefix}${JSON.stringify(value)}`);
114
+ const candidate = lines.join(eol);
115
+ const candidateCloseIdx = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
116
+ const candidateDocument = parseDocument(lines.slice(1, candidateCloseIdx).join("\n"));
117
+ const candidateDescription = candidateDocument.get("description", true);
118
+ if (candidateDocument.errors.length > 0 ||
119
+ !isScalar(candidateDescription) ||
120
+ candidateDescription.value !== value) {
121
+ return null;
122
+ }
123
+ return candidate;
62
124
  }
63
- return lines.join("\n");
125
+ return null;
64
126
  }
65
127
  function checkMissingUpdated(data, frontmatterText) {
66
128
  return frontmatterText !== null && !("updated" in data);
@@ -473,16 +535,27 @@ export function runBaseChecks(ctx) {
473
535
  const unquotedColonDetail = checkUnquotedDescriptionColon(ctx.frontmatter);
474
536
  if (unquotedColonDetail) {
475
537
  if (ctx.fix) {
476
- currentRaw = fixUnquotedColon(currentRaw);
477
- modified = true;
478
- const issue = {
479
- file: ctx.relPath,
480
- issue: "unquoted-colon",
481
- detail: unquotedColonDetail,
482
- fixed: true,
483
- };
484
- issues.push(issue);
485
- pendingFixes.push(issue);
538
+ const fixedRaw = fixUnquotedColon(currentRaw);
539
+ if (fixedRaw === null) {
540
+ issues.push({
541
+ file: ctx.relPath,
542
+ issue: "unquoted-colon",
543
+ detail: `${unquotedColonDetail} — could not construct a valid YAML replacement`,
544
+ fixed: "failed",
545
+ });
546
+ }
547
+ else {
548
+ currentRaw = fixedRaw;
549
+ modified = true;
550
+ const issue = {
551
+ file: ctx.relPath,
552
+ issue: "unquoted-colon",
553
+ detail: unquotedColonDetail,
554
+ fixed: true,
555
+ };
556
+ issues.push(issue);
557
+ pendingFixes.push(issue);
558
+ }
486
559
  }
487
560
  else {
488
561
  issues.push({
@@ -1824,7 +1824,12 @@ export function recomputeUtilityScores(db, stateDb) {
1824
1824
  SUM(CASE WHEN u.event_type = 'show' THEN 1 ELSE 0 END) AS show_count,
1825
1825
  SUM(CASE WHEN u.event_type = 'feedback' AND u.signal = 'positive' THEN 1 ELSE 0 END) AS positive_feedback_count,
1826
1826
  SUM(CASE WHEN u.event_type = 'feedback' AND u.signal = 'negative' THEN 1 ELSE 0 END) AS negative_feedback_count,
1827
- MAX(u.created_at) AS last_used_at
1827
+ MAX(
1828
+ CASE
1829
+ WHEN u.event_type IN ('search', 'show', 'curate') THEN u.created_at
1830
+ ELSE NULL
1831
+ END
1832
+ ) AS last_used_at
1828
1833
  FROM usage_events u
1829
1834
  WHERE u.entry_id IS NOT NULL
1830
1835
  AND u.source = 'user'
@@ -1839,7 +1844,6 @@ export function recomputeUtilityScores(db, stateDb) {
1839
1844
  for (const row of scoreRows) {
1840
1845
  existingScores.set(row.entry_id, { utility: row.utility, lastUsedAt: row.last_used_at ?? undefined });
1841
1846
  }
1842
- const now = new Date().toISOString();
1843
1847
  const entryIds = new Set([...existingScores.keys(), ...usageByEntry.keys()]);
1844
1848
  for (const entryId of entryIds) {
1845
1849
  const row = usageByEntry.get(entryId) ?? {
@@ -1857,7 +1861,17 @@ export function recomputeUtilityScores(db, stateDb) {
1857
1861
  const existing = existingScores.get(row.entry_id);
1858
1862
  const prevUtility = existing?.utility ?? 0;
1859
1863
  const utility = prevUtility * emaDecay + effectiveRate * emaNew;
1860
- const lastUsedAt = effectiveRate > 0.5 ? now : (existing?.lastUsedAt ?? undefined);
1864
+ // `utility_scores.last_used_at` is consumed by salience as the timestamp of
1865
+ // the most-recent retrieval. Preserve that meaning by carrying the event's
1866
+ // timestamp through verbatim. The former `effectiveRate > 0.5 ? now : ...`
1867
+ // branch stamped every high-select-rate entry with the index run time,
1868
+ // making unrelated assets look simultaneously fresh and flattening the
1869
+ // recency component of retrieval salience.
1870
+ //
1871
+ // `usage_events` is the source of truth within its retention window. A
1872
+ // missing row therefore clears legacy/index-time stamps on the next index
1873
+ // pass; salience already treats an absent timestamp as long ago.
1874
+ const lastUsedAt = row.last_used_at ?? undefined;
1861
1875
  upsertUtilityScore(db, row.entry_id, {
1862
1876
  utility,
1863
1877
  showCount: row.show_count,
@@ -107,21 +107,22 @@ export function getConsecutiveNoOps(db, ref) {
107
107
  }
108
108
  // ── New in #672 part 2 ────────────────────────────────────────────────────────
109
109
  /**
110
- * Load the `retrieval_salience` column for the top `limit` assets ordered by
111
- * `rank_score` descending.
110
+ * Load every observed retrieval-salience value for a currently resolvable
111
+ * asset. Zero is the no-observation floor and is excluded so corpus sparsity
112
+ * does not masquerade as score entrenchment.
112
113
  *
113
- * Absorbs the health read formerly inlined in `commands/health/metrics.ts`
114
- * (`computeDegradationMetrics`'s corpus-diversity Gini calculation) same
115
- * query, now parameterised on `limit` instead of a hardcoded 100. This is new
116
- * behavior (a named, parameterised repository entry point), not a verbatim
117
- * move; callers that need fail-open behaviour on a missing table (pre-WS-1
118
- * installs) must wrap the call in their own try/catch, matching the existing
119
- * call site.
114
+ * Health computes its distribution diagnostic over this full observed set.
115
+ * Sampling the top-N rows by `rank_score` first truncates the distribution and
116
+ * can make a healthy corpus look artificially uniform.
120
117
  */
121
- export function getTopRetrievalSalience(db, limit) {
118
+ export function getObservedRetrievalSalience(db) {
122
119
  return db
123
- .prepare(`SELECT retrieval_salience FROM asset_salience ORDER BY rank_score DESC LIMIT ?`)
124
- .all(limit);
120
+ .prepare(`SELECT retrieval_salience
121
+ FROM asset_salience
122
+ WHERE retrieval_salience > 0
123
+ AND missing_since IS NULL
124
+ ORDER BY retrieval_salience ASC`)
125
+ .all();
125
126
  }
126
127
  /** List every `asset_salience` ref with its current `missing_since` marker. */
127
128
  export function listAssetSalienceMissingState(db) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.1-beta.2",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "description": "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 Cursor.",
6
6
  "keywords": [