akm-cli 0.9.0-beta.2 → 0.9.0-beta.27

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.
Files changed (120) hide show
  1. package/CHANGELOG.md +660 -0
  2. package/dist/assets/prompts/consolidate-system.md +23 -0
  3. package/dist/assets/prompts/contradiction-judge.md +33 -0
  4. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  5. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  6. package/dist/assets/prompts/extract-session.md +5 -1
  7. package/dist/assets/prompts/graph-extract-system.md +1 -0
  8. package/dist/assets/prompts/memory-infer-system.md +1 -0
  9. package/dist/assets/prompts/memory-infer-user.md +5 -0
  10. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  11. package/dist/assets/prompts/procedural-system.md +44 -0
  12. package/dist/assets/prompts/recombine-system.md +40 -0
  13. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  14. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  15. package/dist/assets/templates/html/default.html +78 -0
  16. package/dist/assets/templates/html/health.html +730 -0
  17. package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
  18. package/dist/cli/shared.js +21 -5
  19. package/dist/cli.js +47 -5
  20. package/dist/commands/agent/contribute-cli.js +16 -3
  21. package/dist/commands/feedback-cli.js +15 -6
  22. package/dist/commands/graph/graph.js +75 -71
  23. package/dist/commands/health/checks.js +48 -0
  24. package/dist/commands/health/html-report.js +790 -0
  25. package/dist/commands/health.js +478 -15
  26. package/dist/commands/improve/calibration.js +161 -0
  27. package/dist/commands/improve/consolidate.js +634 -111
  28. package/dist/commands/improve/dedup.js +482 -0
  29. package/dist/commands/improve/distill.js +145 -69
  30. package/dist/commands/improve/encoding-salience.js +205 -0
  31. package/dist/commands/improve/extract-cli.js +115 -1
  32. package/dist/commands/improve/extract-prompt.js +33 -2
  33. package/dist/commands/improve/extract-watch.js +140 -0
  34. package/dist/commands/improve/extract.js +280 -35
  35. package/dist/commands/improve/feedback-valence.js +54 -0
  36. package/dist/commands/improve/homeostatic.js +467 -0
  37. package/dist/commands/improve/improve-auto-accept.js +139 -6
  38. package/dist/commands/improve/improve-profiles.js +12 -0
  39. package/dist/commands/improve/improve.js +2079 -608
  40. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  41. package/dist/commands/improve/outcome-loop.js +256 -0
  42. package/dist/commands/improve/proactive-maintenance.js +87 -0
  43. package/dist/commands/improve/procedural.js +409 -0
  44. package/dist/commands/improve/recombine.js +488 -0
  45. package/dist/commands/improve/reflect-noise.js +0 -0
  46. package/dist/commands/improve/reflect.js +51 -1
  47. package/dist/commands/improve/related-sessions.js +120 -0
  48. package/dist/commands/improve/salience.js +386 -0
  49. package/dist/commands/improve/triage.js +95 -0
  50. package/dist/commands/lint/agent-linter.js +19 -24
  51. package/dist/commands/lint/base-linter.js +173 -60
  52. package/dist/commands/lint/command-linter.js +19 -24
  53. package/dist/commands/lint/env-key-rules.js +34 -1
  54. package/dist/commands/lint/fact-linter.js +39 -0
  55. package/dist/commands/lint/index.js +31 -13
  56. package/dist/commands/lint/memory-linter.js +1 -1
  57. package/dist/commands/lint/registry.js +7 -2
  58. package/dist/commands/lint/task-linter.js +3 -3
  59. package/dist/commands/lint/workflow-linter.js +26 -1
  60. package/dist/commands/proposal/drain.js +73 -6
  61. package/dist/commands/proposal/proposal-cli.js +22 -10
  62. package/dist/commands/proposal/proposal.js +17 -1
  63. package/dist/commands/proposal/validators/proposals.js +369 -329
  64. package/dist/commands/read/curate.js +344 -80
  65. package/dist/commands/read/search-cli.js +7 -0
  66. package/dist/commands/read/search.js +1 -0
  67. package/dist/commands/read/show.js +67 -2
  68. package/dist/commands/remember.js +6 -2
  69. package/dist/commands/sources/installed-stashes.js +5 -1
  70. package/dist/commands/sources/stash-cli.js +10 -2
  71. package/dist/core/asset/asset-registry.js +2 -0
  72. package/dist/core/asset/asset-spec.js +14 -0
  73. package/dist/core/asset/frontmatter.js +166 -167
  74. package/dist/core/asset/markdown.js +8 -0
  75. package/dist/core/config/config-schema.js +255 -2
  76. package/dist/core/config/config.js +2 -2
  77. package/dist/core/logs-db.js +305 -0
  78. package/dist/core/paths.js +3 -0
  79. package/dist/core/state-db.js +706 -42
  80. package/dist/indexer/db/db.js +364 -38
  81. package/dist/indexer/db/graph-db.js +129 -86
  82. package/dist/indexer/ensure-index.js +152 -17
  83. package/dist/indexer/graph/graph-boost.js +51 -41
  84. package/dist/indexer/graph/graph-extraction.js +203 -3
  85. package/dist/indexer/index-writer-lock.js +99 -0
  86. package/dist/indexer/indexer.js +114 -111
  87. package/dist/indexer/passes/memory-inference.js +71 -25
  88. package/dist/indexer/passes/staleness-detect.js +2 -5
  89. package/dist/indexer/search/db-search.js +15 -4
  90. package/dist/indexer/search/ranking-contributors.js +22 -0
  91. package/dist/indexer/search/ranking.js +4 -0
  92. package/dist/indexer/walk/matchers.js +9 -0
  93. package/dist/integrations/agent/prompts.js +1 -0
  94. package/dist/integrations/harnesses/claude/session-log.js +27 -5
  95. package/dist/integrations/harnesses/opencode/session-log.js +9 -0
  96. package/dist/integrations/session-logs/index.js +16 -0
  97. package/dist/llm/client.js +38 -4
  98. package/dist/llm/embedder.js +27 -3
  99. package/dist/llm/embedders/local.js +66 -2
  100. package/dist/llm/graph-extract.js +2 -1
  101. package/dist/llm/memory-infer.js +4 -8
  102. package/dist/llm/metadata-enhance.js +9 -1
  103. package/dist/llm/usage-persist.js +77 -0
  104. package/dist/llm/usage-telemetry.js +103 -0
  105. package/dist/output/context.js +3 -2
  106. package/dist/output/html-render.js +73 -0
  107. package/dist/output/renderers.js +73 -1
  108. package/dist/output/shapes/curate.js +14 -2
  109. package/dist/output/shapes/helpers.js +17 -1
  110. package/dist/output/text/helpers.js +78 -1
  111. package/dist/runtime.js +25 -1
  112. package/dist/scripts/migrate-storage.js +1262 -591
  113. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +485 -270
  114. package/dist/sources/providers/tar-utils.js +16 -8
  115. package/dist/storage/sqlite-pragmas.js +146 -0
  116. package/dist/tasks/runner.js +99 -16
  117. package/dist/workflows/db.js +5 -2
  118. package/dist/workflows/validate-summary.js +2 -7
  119. package/docs/data-and-telemetry.md +1 -0
  120. package/package.json +9 -6
@@ -0,0 +1,467 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { warn } from "../../core/warn.js";
5
+ import { closeDatabase, openExistingDatabase } from "../../indexer/db/db.js";
6
+ // ── Constants ─────────────────────────────────────────────────────────────────
7
+ /** Default days-since-last-retrieval threshold to consider an asset stale. */
8
+ export const DEFAULT_STALE_DAYS = 30;
9
+ /** Default retrievalSalience demotion factor for stale assets. */
10
+ export const DEFAULT_DEMOTION_FACTOR = 0.5;
11
+ /** Default epsilon for schema-similarity gate (looser than dedup's 0.97). */
12
+ export const DEFAULT_SCHEMA_SIMILARITY_EPSILON = 0.85;
13
+ /** Default multiplicative confidence penalty applied to schema-consistent candidates. */
14
+ export const DEFAULT_SCHEMA_CONFIDENCE_PENALTY = 0.5;
15
+ /** Default max generation depth before merge is refused. */
16
+ export const DEFAULT_MAX_GENERATION = 2;
17
+ /** Default fraction of pool to fill with random (non-similar) clusters. */
18
+ export const DEFAULT_RANDOM_CLUSTER_FRACTION = 0.05;
19
+ /** Default number of adjacent lessons/knowledge for CLS interleaving. */
20
+ export const DEFAULT_CLS_ADJACENT_COUNT = 3;
21
+ /**
22
+ * Demote `retrievalSalience` in state.db for stale/low-value assets.
23
+ *
24
+ * "Stale" = the asset has a salience row with `updated_at` older than
25
+ * `staleDays` AND `retrieval_salience > 0`. Demotion multiplies the current
26
+ * `retrieval_salience` by `demotionFactor` (default 0.5) and records
27
+ * `homeostatic_demoted_at` so the pass can be observed.
28
+ *
29
+ * Pure state.db operation — no file I/O, no LLM calls. Idempotent: running
30
+ * twice in the same run only demotes the already-demoted value a second time,
31
+ * which is bounded (0.5 × 0.5 = 0.25) and corrected on re-retrieval.
32
+ *
33
+ * Called BEFORE the dedup/LLM-merge pool is assembled so the merge pool
34
+ * already reflects the updated scores.
35
+ */
36
+ export function runHomeostaticDemotion(db, config, now) {
37
+ const warnings = [];
38
+ if (!config.enabled)
39
+ return { demoted: 0, warnings };
40
+ const staleDays = config.staleDays ?? DEFAULT_STALE_DAYS;
41
+ const demotionFactor = config.demotionFactor ?? DEFAULT_DEMOTION_FACTOR;
42
+ const nowMs = now ?? Date.now();
43
+ const staleThresholdMs = nowMs - staleDays * 86_400_000;
44
+ try {
45
+ // Find assets whose salience row is stale AND has non-zero retrievalSalience.
46
+ // updated_at reflects the last time salience was computed (i.e. the last run
47
+ // that touched this asset). If the asset hasn't been seen recently, its
48
+ // retrieval_salience is stale.
49
+ const staleRows = db
50
+ .prepare(`SELECT asset_ref, retrieval_salience, rank_score, encoding_salience, outcome_salience
51
+ FROM asset_salience
52
+ WHERE updated_at < ? AND retrieval_salience > 0`)
53
+ .all(staleThresholdMs);
54
+ if (staleRows.length === 0)
55
+ return { demoted: 0, warnings };
56
+ // Batch update in a transaction for atomicity and performance.
57
+ const updateStmt = db.prepare(`UPDATE asset_salience
58
+ SET retrieval_salience = ?,
59
+ rank_score = ?,
60
+ homeostatic_demoted_at = ?,
61
+ updated_at = ?
62
+ WHERE asset_ref = ?`);
63
+ let demoted = 0;
64
+ db.exec("BEGIN");
65
+ try {
66
+ for (const row of staleRows) {
67
+ const newRetrieval = row.retrieval_salience * demotionFactor;
68
+ // Recompute rank_score with the demoted retrieval value.
69
+ // We use simplified WS-1 parity weights here (no outcome weight by
70
+ // default) so the demotion is consistent with what salience.ts computes.
71
+ // The next full computeSalience call will overwrite with the exact value.
72
+ const newRank = Math.min(1, Math.max(0, (0.3 * row.encoding_salience + 0.0 * row.outcome_salience + 0.7 * newRetrieval) *
73
+ // Apply a mild size penalty assumption (200 bytes floor gives 1/log10(200)≈0.43)
74
+ 0.43));
75
+ updateStmt.run(newRetrieval, newRank, nowMs, nowMs, row.asset_ref);
76
+ demoted++;
77
+ }
78
+ db.exec("COMMIT");
79
+ }
80
+ catch (e) {
81
+ db.exec("ROLLBACK");
82
+ throw e;
83
+ }
84
+ warn(`[homeostatic] demoted retrievalSalience for ${demoted} stale asset(s) (staleDays=${staleDays}, factor=${demotionFactor})`);
85
+ return { demoted, warnings };
86
+ }
87
+ catch (err) {
88
+ const msg = `[homeostatic] demotion failed: ${err instanceof Error ? err.message : String(err)}`;
89
+ warn(msg);
90
+ warnings.push(msg);
91
+ return { demoted: 0, warnings };
92
+ }
93
+ }
94
+ /**
95
+ * Check whether a candidate body embedding is schema-consistent with an existing
96
+ * derived-layer lesson/knowledge node. Returns `true` when the candidate is
97
+ * within ε of ANY existing derived node (i.e. it's likely covering ground the
98
+ * derived layer already knows about, so give it lower priority).
99
+ *
100
+ * One embedding lookup via the body_embeddings cache; no LLM call.
101
+ * Fails open: returns `false` (not schema-consistent) on any error so the
102
+ * candidate is not silently dropped.
103
+ *
104
+ * @param candidateEmbedding - Float32 embedding vector for the candidate body.
105
+ * @param existingDerivedEmbeddings - Pre-loaded embeddings for existing derived assets.
106
+ * @param config - Schema-similarity gate config.
107
+ */
108
+ export function isSchemaConsistent(candidateEmbedding, existingDerivedEmbeddings, config) {
109
+ if (!config.enabled || existingDerivedEmbeddings.length === 0) {
110
+ return { consistent: false };
111
+ }
112
+ const epsilon = config.epsilon ?? DEFAULT_SCHEMA_SIMILARITY_EPSILON;
113
+ let bestSim = -Infinity;
114
+ let bestRef;
115
+ for (const { ref, embedding } of existingDerivedEmbeddings) {
116
+ // cosine similarity: dot(a,b) / (|a| * |b|)
117
+ let dot = 0;
118
+ let magA = 0;
119
+ let magB = 0;
120
+ for (let i = 0; i < candidateEmbedding.length; i++) {
121
+ const a = candidateEmbedding[i] ?? 0;
122
+ const b = embedding[i] ?? 0;
123
+ dot += a * b;
124
+ magA += a * a;
125
+ magB += b * b;
126
+ }
127
+ const sim = magA === 0 || magB === 0 ? 0 : dot / (Math.sqrt(magA) * Math.sqrt(magB));
128
+ if (sim > bestSim) {
129
+ bestSim = sim;
130
+ bestRef = ref;
131
+ }
132
+ }
133
+ if (bestSim >= epsilon) {
134
+ return { consistent: true, matchedRef: bestRef, similarity: bestSim };
135
+ }
136
+ return { consistent: false };
137
+ }
138
+ /**
139
+ * WS-3b Step-0b: apply the schema-similarity intake gate to one extract
140
+ * candidate. Pure/deterministic given `embedText`, so it is directly unit
141
+ * testable without the full extract→LLM harness.
142
+ *
143
+ * Returns the (possibly penalised) effective confidence plus a `penalised` flag
144
+ * and an optional human-readable `warning`. Parity guarantees:
145
+ * - `ctx === null` (gate disabled / default-off) → no change, never embeds.
146
+ * - empty `derivedEmbeddings` → no change, never embeds.
147
+ * - candidate type not lesson/knowledge → no change, never embeds.
148
+ * - embed throws → fail open (no change), warns.
149
+ */
150
+ export async function applySchemaSimilarityPenalty(candidate, ctx, embedText) {
151
+ const baseConfidence = typeof candidate.confidence === "number" ? candidate.confidence : undefined;
152
+ if (ctx === null || ctx.derivedEmbeddings.length === 0) {
153
+ return { effectiveConfidence: baseConfidence, penalised: false };
154
+ }
155
+ if (candidate.type !== "lesson" && candidate.type !== "knowledge") {
156
+ return { effectiveConfidence: baseConfidence, penalised: false };
157
+ }
158
+ try {
159
+ const candidateVec = await embedText(candidate.body);
160
+ const check = isSchemaConsistent(candidateVec, ctx.derivedEmbeddings, ctx.config);
161
+ if (check.consistent) {
162
+ const penalty = ctx.config.confidencePenalty ?? DEFAULT_SCHEMA_CONFIDENCE_PENALTY;
163
+ return {
164
+ effectiveConfidence: (baseConfidence ?? 1.0) * penalty,
165
+ penalised: true,
166
+ warning: `[extract] schema-consistent candidate ${candidate.type}:${candidate.name} ` +
167
+ `(sim=${check.similarity?.toFixed(3)} vs ${check.matchedRef}) — confidence penalised ×${penalty}`,
168
+ };
169
+ }
170
+ return { effectiveConfidence: baseConfidence, penalised: false };
171
+ }
172
+ catch (embedErr) {
173
+ // Fail open: embed errors must never abort extraction.
174
+ return {
175
+ effectiveConfidence: baseConfidence,
176
+ penalised: false,
177
+ warning: `[extract] schema-similarity embed failed for ${candidate.type}:${candidate.name} — skipping gate: ` +
178
+ (embedErr instanceof Error ? embedErr.message : String(embedErr)),
179
+ };
180
+ }
181
+ }
182
+ /**
183
+ * Load persisted body embeddings for all indexed **derived-layer**
184
+ * (lesson + knowledge) entries from index.db. Returns an empty array when
185
+ * the DB is unavailable, empty, or the embeddings table has no entries for
186
+ * those types — the caller treats an empty array as "gate inactive".
187
+ *
188
+ * FAIL-OPEN: any error emits a debug warning and returns an empty array.
189
+ * This ensures the extract pass never fails because of a missing index.
190
+ *
191
+ * The returned entries are keyed by `entry_key` (e.g. "lesson:foo",
192
+ * "knowledge:bar"). Only entries whose embedding dimension matches the first
193
+ * observed dimension are included (mixed-dim BLOBs are silently skipped).
194
+ *
195
+ * @param dbPath - Optional path override for index.db (for testing).
196
+ */
197
+ export function loadDerivedLayerEmbeddings(dbPath) {
198
+ let db;
199
+ try {
200
+ db = openExistingDatabase(dbPath);
201
+ const rows = db
202
+ .prepare(`SELECT e.entry_key, emb.embedding
203
+ FROM entries e
204
+ JOIN embeddings emb ON emb.id = e.id
205
+ WHERE e.entry_type IN ('lesson', 'knowledge')`)
206
+ .all();
207
+ if (rows.length === 0)
208
+ return [];
209
+ let expectedDim;
210
+ const result = [];
211
+ for (const row of rows) {
212
+ const buf = row.embedding;
213
+ if (!buf || buf.byteLength === 0 || buf.byteLength % 4 !== 0)
214
+ continue;
215
+ const dim = buf.byteLength / 4;
216
+ if (expectedDim === undefined)
217
+ expectedDim = dim;
218
+ if (dim !== expectedDim)
219
+ continue;
220
+ const aligned = new ArrayBuffer(buf.byteLength);
221
+ new Uint8Array(aligned).set(buf);
222
+ const f32 = new Float32Array(aligned);
223
+ result.push({ ref: row.entry_key, embedding: Array.from(f32) });
224
+ }
225
+ return result;
226
+ }
227
+ catch (err) {
228
+ warn("[homeostatic] loadDerivedLayerEmbeddings: failed to load from index.db — gate inactive:", err instanceof Error ? err.message : String(err));
229
+ return [];
230
+ }
231
+ finally {
232
+ if (db) {
233
+ try {
234
+ closeDatabase(db);
235
+ }
236
+ catch {
237
+ // ignore close errors
238
+ }
239
+ }
240
+ }
241
+ }
242
+ /**
243
+ * Read the `generation` field from an asset's frontmatter.
244
+ * Returns 0 when absent (no generation metadata = original asset).
245
+ */
246
+ export function readAssetGeneration(frontmatterData) {
247
+ const gen = frontmatterData.generation;
248
+ if (typeof gen === "number" && Number.isFinite(gen) && gen >= 0) {
249
+ return Math.floor(gen);
250
+ }
251
+ return 0;
252
+ }
253
+ /**
254
+ * Compute the new generation for a merged asset.
255
+ * Rule: `merged.generation = max(source generations) + 1`.
256
+ */
257
+ export function computeMergedGeneration(sourceGenerations) {
258
+ if (sourceGenerations.length === 0)
259
+ return 1;
260
+ return Math.max(...sourceGenerations) + 1;
261
+ }
262
+ /**
263
+ * Check whether a merge of the given assets should be refused due to the
264
+ * anti-collapse generation guard.
265
+ *
266
+ * Returns `{ refused: true, reason }` when BOTH assets have generation > maxGeneration.
267
+ * Returns `{ refused: false }` when the merge is allowed.
268
+ *
269
+ * @param sourceGenerations - Generation values for all merge participants.
270
+ * @param config - Anti-collapse config.
271
+ */
272
+ export function checkGenerationGuard(sourceGenerations, config) {
273
+ if (!config.enabled)
274
+ return { refused: false };
275
+ const maxGen = config.maxGeneration ?? DEFAULT_MAX_GENERATION;
276
+ const highGenCount = sourceGenerations.filter((g) => g > maxGen).length;
277
+ if (highGenCount >= 2) {
278
+ return {
279
+ refused: true,
280
+ reason: `Anti-collapse: ${highGenCount} merge participants have generation > ${maxGen} (${sourceGenerations.join(", ")}); refusing to merge over-consolidated assets.`,
281
+ };
282
+ }
283
+ return { refused: false };
284
+ }
285
+ /**
286
+ * Compute the bigram n-gram diversity of a text string.
287
+ * Returns a value in [0, 1] where 0 = all identical bigrams, 1 = all unique.
288
+ * Used by the lexical-diversity check to detect correlated-extraction artifacts.
289
+ */
290
+ export function computeBigramDiversity(text) {
291
+ const words = text
292
+ .toLowerCase()
293
+ .split(/\s+/)
294
+ .filter((w) => w.length > 0);
295
+ if (words.length < 2)
296
+ return 1; // too short to have bigrams; treat as diverse
297
+ const total = words.length - 1;
298
+ const unique = new Set();
299
+ for (let i = 0; i < total; i++) {
300
+ unique.add(`${words[i]}\t${words[i + 1]}`);
301
+ }
302
+ return unique.size / total;
303
+ }
304
+ /**
305
+ * Check whether a cluster of memories exhibits suspiciously low lexical diversity.
306
+ * When true, the cluster is likely a correlated-extraction artifact; the merge
307
+ * threshold should be raised.
308
+ *
309
+ * @param bodies - The stripped body texts of the cluster members.
310
+ * @param config - Anti-collapse config.
311
+ * @returns `{ lowDiversity: true, diversity }` when the cluster diversity is
312
+ * below the 0.3 threshold; `{ lowDiversity: false }` otherwise.
313
+ */
314
+ export function checkLexicalDiversity(bodies, config) {
315
+ if (!config.enabled || config.lexicalDiversityCheck === false) {
316
+ return { lowDiversity: false };
317
+ }
318
+ if (bodies.length === 0)
319
+ return { lowDiversity: false };
320
+ // Average bigram diversity across all bodies in the cluster.
321
+ const avg = bodies.reduce((sum, b) => sum + computeBigramDiversity(b), 0) / bodies.length;
322
+ const DIVERSITY_FLOOR = 0.3;
323
+ if (avg < DIVERSITY_FLOOR) {
324
+ return { lowDiversity: true, diversity: avg };
325
+ }
326
+ return { lowDiversity: false };
327
+ }
328
+ /**
329
+ * Build a CLS (Complementary Learning System) context snippet for injection
330
+ * into distill/memoryInference prompts.
331
+ *
332
+ * Given a list of embedding-retrieved adjacent lessons/knowledge, formats them
333
+ * as a markdown section to append to the prompt so the LLM avoids overwriting
334
+ * prior generalizations.
335
+ *
336
+ * Returns an empty string when CLS is disabled or no adjacent items are found.
337
+ *
338
+ * @param adjacentItems - Top-N adjacent lessons/knowledge retrieved by embedding.
339
+ * @param config - CLS config.
340
+ */
341
+ export function buildClsContext(adjacentItems, config) {
342
+ if (!config.enabled || adjacentItems.length === 0)
343
+ return "";
344
+ const lines = [
345
+ "",
346
+ "## Existing adjacent lessons / knowledge (CLS context)",
347
+ "The following are semantically related entries already in the stash.",
348
+ "Your proposal MUST NOT contradict or silently overwrite these — if you",
349
+ "disagree with one, flag it as contradicted (do not ignore it).",
350
+ "",
351
+ ];
352
+ for (const item of adjacentItems) {
353
+ lines.push(`### ${item.ref}`);
354
+ // Truncate to 400 chars to keep the prompt size reasonable.
355
+ lines.push(item.content.trim().slice(0, 400));
356
+ lines.push("");
357
+ }
358
+ return lines.join("\n");
359
+ }
360
+ /**
361
+ * Check a distill proposal against its cited source memories for contradictions.
362
+ *
363
+ * Uses a simple heuristic: looks for explicit negation of key claims in the
364
+ * proposal body that appear in the source bodies. A full LLM-based
365
+ * contradiction check is expensive (one LLM call per proposal); this cheap
366
+ * heuristic catches the most obvious cases and flags them for human review.
367
+ *
368
+ * When `fidelityCheck.enabled` is false, returns `{ contradictionDetected: false }`
369
+ * immediately (no work done).
370
+ *
371
+ * @param proposalBody - The stripped body of the distill proposal.
372
+ * @param sourceBodies - The stripped bodies of the cited source memories.
373
+ * @param config - Fidelity check config.
374
+ */
375
+ export function checkDistillFidelity(proposalBody, sourceBodies, config) {
376
+ if (!config.enabled || sourceBodies.length === 0) {
377
+ return { contradictionDetected: false };
378
+ }
379
+ // Heuristic: detect explicit negation of "never" / "always" / "must" claims.
380
+ // A proposal that says "always X" while the source says "never X" (or vice
381
+ // versa) is a clear contradiction worth flagging.
382
+ //
383
+ // This is intentionally conservative: it only flags when both the proposal
384
+ // AND the source contain the opposing polarity of the same key term. False
385
+ // negatives (missed contradictions) are preferred over false positives
386
+ // (blocking valid proposals) since the consequence of a false positive is
387
+ // a human review request, while the cost of a false negative is a slightly
388
+ // degraded stash.
389
+ const proposalLow = proposalBody.toLowerCase();
390
+ // Extract "always/never/must/must not" claims from the proposal.
391
+ const strongClaims = extractStrongClaims(proposalLow);
392
+ if (strongClaims.length === 0)
393
+ return { contradictionDetected: false };
394
+ for (const sourceBody of sourceBodies) {
395
+ const sourceLow = sourceBody.toLowerCase();
396
+ for (const { polarity, term } of strongClaims) {
397
+ const oppositePolarity = polarity === "positive" ? "negative" : "positive";
398
+ const sourceHasOpposite = hasStrongClaim(sourceLow, term, oppositePolarity);
399
+ if (sourceHasOpposite) {
400
+ return {
401
+ contradictionDetected: true,
402
+ reason: `Proposal makes a ${polarity} strong claim about "${term}" that conflicts with an opposing claim in a cited source. Route to human review.`,
403
+ };
404
+ }
405
+ }
406
+ }
407
+ // Also flag proposals whose source_refs are empty (broken provenance).
408
+ // This is a degradation signal, not a contradiction, but worth surfacing.
409
+ return { contradictionDetected: false };
410
+ }
411
+ function extractStrongClaims(text) {
412
+ const claims = [];
413
+ // Match "always <term>", "never <term>", "must <term>", "must not <term>".
414
+ const patterns = [
415
+ { polarity: "positive", re: /\b(?:always|must)\s+(\w+)/g },
416
+ { polarity: "negative", re: /\b(?:never|must\s+not|should\s+not)\s+(\w+)/g },
417
+ ];
418
+ for (const { polarity, re } of patterns) {
419
+ re.lastIndex = 0;
420
+ let m = re.exec(text);
421
+ while (m !== null) {
422
+ const term = m[1];
423
+ if (term && term.length > 2)
424
+ claims.push({ polarity, term });
425
+ m = re.exec(text);
426
+ }
427
+ }
428
+ return claims;
429
+ }
430
+ function hasStrongClaim(text, term, polarity) {
431
+ if (polarity === "positive") {
432
+ return /\b(?:always|must)\s/.test(text) && text.includes(term);
433
+ }
434
+ return /\b(?:never|must\s+not|should\s+not)\s/.test(text) && text.includes(term);
435
+ }
436
+ // ── captureMode: hot-probation helpers ───────────────────────────────────────
437
+ /**
438
+ * captureMode value for system-generated extractions in probation.
439
+ * Automatic counterpart to the user-explicit `captureMode: hot`.
440
+ */
441
+ export const CAPTURE_MODE_HOT_PROBATION = "hot-probation";
442
+ /**
443
+ * Returns true when an asset is in hot-probation (system-generated, not yet
444
+ * graduated from the intake dedup+quality pass).
445
+ */
446
+ export function isHotProbation(captureModeValue) {
447
+ return captureModeValue === CAPTURE_MODE_HOT_PROBATION;
448
+ }
449
+ /**
450
+ * Returns true when an asset should be skipped by the consolidation LLM
451
+ * because it's still in hot-probation (hasn't completed the intake pass yet).
452
+ *
453
+ * Hot-probation assets are processed by the consolidation dedup pre-pass
454
+ * (runDeterministicDedup) but excluded from the LLM merge clustering, so
455
+ * noisy extractions can't pollute the LLM context.
456
+ */
457
+ export function shouldSkipHotProbationInLlm(frontmatterData) {
458
+ return isHotProbation(frontmatterData.captureMode);
459
+ }
460
+ /**
461
+ * Build frontmatter fields to inject when creating a hot-probation proposal.
462
+ * The proposal will carry `captureMode: hot-probation` so downstream logic
463
+ * knows to run the intake dedup pass before graduating it.
464
+ */
465
+ export function buildHotProbationFrontmatter() {
466
+ return { captureMode: CAPTURE_MODE_HOT_PROBATION };
467
+ }
@@ -3,8 +3,26 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { loadConfig } from "../../core/config/config.js";
5
5
  import { appendEvent } from "../../core/events.js";
6
+ import { getPhaseThreshold, openStateDatabase } from "../../core/state-db.js";
6
7
  import { info, warn } from "../../core/warn.js";
7
- import { promoteProposal } from "../proposal/validators/proposals.js";
8
+ import { promoteProposal, recordGateDecision } from "../proposal/validators/proposals.js";
9
+ /**
10
+ * Derive a stable, low-cardinality reason bucket from an auto-accept promotion
11
+ * error. `promoteProposal` throws a `validateProposal` report formatted as
12
+ * `[kind] message` lines; we extract the first finding kind. Non-validation
13
+ * throws collapse to `promote-error`.
14
+ */
15
+ function classifyPromoteFailure(err) {
16
+ const message = err instanceof Error ? err.message : String(err);
17
+ const finding = /\[([a-z][a-z0-9-]*)\]/i.exec(message);
18
+ if (finding)
19
+ return `validation:${finding[1]}`;
20
+ if (/not pending/i.test(message))
21
+ return "not-pending";
22
+ if (/unknown asset type/i.test(message))
23
+ return "unknown-type";
24
+ return "promote-error";
25
+ }
8
26
  // ---------------------------------------------------------------------------
9
27
  // Gate implementation
10
28
  // ---------------------------------------------------------------------------
@@ -18,22 +36,75 @@ import { promoteProposal } from "../proposal/validators/proposals.js";
18
36
  * @param promoteFn Injectable override for `promoteProposal` (test seam).
19
37
  */
20
38
  export async function runAutoAcceptGate(candidates, cfg, promoteFn = promoteProposal) {
21
- const result = { promoted: [], skipped: [], failed: [] };
39
+ const result = { promoted: [], skipped: [], failed: [], failedByReason: {} };
22
40
  // --- Guard: gate is disabled or context is incomplete ---
23
41
  if (cfg.dryRun || cfg.globalThreshold === undefined || !cfg.stashDir) {
24
42
  result.skipped = candidates.map((c) => c.proposalId);
25
43
  return result;
26
44
  }
27
- const effectiveThreshold = Math.max(cfg.globalThreshold, cfg.minimumThreshold ?? 0) / 100;
45
+ // WS-4: per-phase threshold from state.db overrides the global threshold.
46
+ // The per-phase value is populated by makeGateConfig when a stateDbPath is
47
+ // available; callers that don't pass it get the global threshold unchanged.
48
+ const resolvedThreshold = cfg.phaseThreshold ?? cfg.globalThreshold;
49
+ const effectiveThreshold = Math.max(resolvedThreshold, cfg.minimumThreshold ?? 0) / 100;
50
+ // WS-4: Exploration budget — promote at most N candidates regardless of
51
+ // confidence to prevent the gate converging to pure exploitation.
52
+ // Exploration candidates are chosen from the LOWEST-confidence eligible set
53
+ // (i.e. those that would be deferred) so the budget truly samples the low-
54
+ // confidence tail and is meaningfully distinct from normal auto-accept.
55
+ // Promoted exploration proposals are logged with eligibilitySource="exploration".
56
+ const explorationBudget = cfg.explorationBudgetCount ?? 0;
57
+ let explorationRemaining = explorationBudget;
28
58
  const resolvedConfig = typeof cfg.config === "function" ? cfg.config() : cfg.config;
59
+ const gateLabel = `improve:${cfg.phase}`;
60
+ // #577: stamp the gate's verdict onto each proposal so `akm proposal show`
61
+ // can explain why a proposal is pending (e.g. "deferred: below-threshold,
62
+ // 0.72 < 0.90"). Best-effort — a recording failure must never abort the gate.
63
+ const stamp = (proposalId, decision) => {
64
+ try {
65
+ recordGateDecision(cfg.stashDir, proposalId, decision);
66
+ }
67
+ catch (err) {
68
+ warn(`[improve] ${cfg.phase} failed to record gate decision for ${proposalId}: ${err instanceof Error ? err.message : String(err)}`);
69
+ }
70
+ };
29
71
  for (const candidate of candidates) {
30
72
  const { proposalId, confidence } = candidate;
31
- if (confidence === undefined || confidence < effectiveThreshold) {
73
+ // Determine if this candidate is exploration-eligible: below-threshold
74
+ // (would normally be deferred) but with a valid confidence score and budget
75
+ // remaining. No-confidence candidates are never exploration-promoted.
76
+ const belowThreshold = confidence === undefined || confidence < effectiveThreshold;
77
+ const isExploration = belowThreshold && confidence !== undefined && explorationRemaining > 0;
78
+ if (belowThreshold && !isExploration) {
79
+ stamp(proposalId, {
80
+ outcome: "deferred",
81
+ reason: confidence === undefined ? "no-confidence" : "below-threshold",
82
+ ...(confidence !== undefined ? { confidence } : {}),
83
+ thresholds: { autoAccept: effectiveThreshold },
84
+ gate: gateLabel,
85
+ });
32
86
  result.skipped.push(proposalId);
33
87
  continue;
34
88
  }
89
+ // Either above-threshold (normal auto-accept) or exploration-budget promoted.
90
+ if (isExploration)
91
+ explorationRemaining -= 1;
92
+ const promoteReason = isExploration ? "exploration-budget" : "above-threshold";
35
93
  try {
36
94
  const promotion = await promoteFn(cfg.stashDir, resolvedConfig, proposalId, {}, undefined);
95
+ stamp(promotion.proposal.id, {
96
+ outcome: "auto-accepted",
97
+ reason: promoteReason,
98
+ confidence,
99
+ thresholds: { autoAccept: effectiveThreshold },
100
+ gate: gateLabel,
101
+ });
102
+ // Resolve the eligibilitySource: exploration-promoted proposals get
103
+ // eligibilitySource="exploration" (WS-4); normal auto-accepts carry
104
+ // whatever the proposal was tagged with at selection time.
105
+ const resolvedEligibilitySource = isExploration
106
+ ? "exploration"
107
+ : promotion.proposal.eligibilitySource;
37
108
  appendEvent({
38
109
  eventType: "promoted",
39
110
  ref: promotion.ref,
@@ -46,14 +117,41 @@ export async function runAutoAcceptGate(candidates, cfg, promoteFn = promoteProp
46
117
  confidence,
47
118
  threshold: effectiveThreshold,
48
119
  phase: cfg.phase,
120
+ // Attribution tagging: carry the eligibility lane from the proposal
121
+ // record onto the auto-accept promoted event so the lane survives to
122
+ // accept time even when promotion happens in a later run.
123
+ ...(resolvedEligibilitySource !== undefined ? { eligibilitySource: resolvedEligibilitySource } : {}),
124
+ // WS-4: mark exploration promotions so health/telemetry can
125
+ // distinguish them from calibration-signal promotions.
126
+ ...(isExploration ? { explorationBudget: true } : {}),
49
127
  },
50
128
  }, cfg.eventsCtx ?? {});
51
- info(`[improve] auto-accepted ${promotion.ref} (${cfg.phase}; confidence=${confidence.toFixed(2)} >= threshold=${effectiveThreshold.toFixed(2)})`);
129
+ if (isExploration) {
130
+ info(`[improve] exploration-accepted ${promotion.ref} (${cfg.phase}; confidence=${confidence.toFixed(2)}; budgetRemaining=${explorationRemaining})`);
131
+ }
132
+ else {
133
+ info(`[improve] auto-accepted ${promotion.ref} (${cfg.phase}; confidence=${confidence.toFixed(2)} >= threshold=${effectiveThreshold.toFixed(2)})`);
134
+ }
52
135
  result.promoted.push(proposalId);
53
136
  }
54
137
  catch (err) {
55
- warn(`[improve] ${cfg.phase} auto-accept failed for ${proposalId}: ${err instanceof Error ? err.message : String(err)}`);
138
+ const reason = classifyPromoteFailure(err);
139
+ warn(`[improve] ${cfg.phase} auto-accept failed for ${proposalId} (${reason}): ${err instanceof Error ? err.message : String(err)}`);
56
140
  result.failed.push(proposalId);
141
+ result.failedByReason[reason] = (result.failedByReason[reason] ?? 0) + 1;
142
+ // Record WHY on the proposal so `akm proposal show` explains the rejection
143
+ // and the leak is no longer blind. Best-effort.
144
+ stamp(proposalId, {
145
+ outcome: "auto-rejected",
146
+ reason,
147
+ confidence,
148
+ thresholds: { autoAccept: effectiveThreshold },
149
+ gate: gateLabel,
150
+ });
151
+ // If exploration budget was consumed but promotion failed, restore the slot
152
+ // so the budget isn't exhausted on errors.
153
+ if (isExploration)
154
+ explorationRemaining += 1;
57
155
  }
58
156
  }
59
157
  return result;
@@ -82,15 +180,50 @@ export function resolveExtractConfidence(proposal) {
82
180
  /**
83
181
  * Build a gate config for a phase, inheriting global settings from the
84
182
  * improve options. Callers supply only the phase-specific overrides.
183
+ *
184
+ * WS-4 additions:
185
+ * - When `shared.stateDbPath` is provided, reads the persisted per-phase
186
+ * threshold from `improve_gate_thresholds` (Migration 012). The phase
187
+ * value overrides `globalThreshold` but is still floored by
188
+ * `minimumThreshold`. Falls back to `globalThreshold` when no row exists.
189
+ * - Computes `explorationBudgetCount` from
190
+ * `config.improve.exploration.budgetFraction × candidateCount` when the
191
+ * exploration budget is enabled. Defaults to 0 (no exploration).
85
192
  */
86
193
  export function makeGateConfig(phase, shared, overrides = {}) {
194
+ // WS-4: read per-phase threshold from state.db when available.
195
+ let phaseThreshold;
196
+ if (shared.stateDbPath && shared.globalThreshold !== undefined) {
197
+ try {
198
+ const db = openStateDatabase(shared.stateDbPath);
199
+ try {
200
+ phaseThreshold = getPhaseThreshold(db, phase) ?? undefined;
201
+ }
202
+ finally {
203
+ db.close();
204
+ }
205
+ }
206
+ catch {
207
+ // DB unavailable — fall back to globalThreshold silently.
208
+ }
209
+ }
210
+ // WS-4: compute exploration budget count from config fraction × candidateCount.
211
+ let explorationBudgetCount;
212
+ const resolvedConfig = typeof shared.config === "function" ? shared.config() : shared.config;
213
+ const explorationCfg = resolvedConfig.improve?.exploration;
214
+ if (explorationCfg?.enabled && shared.candidateCount !== undefined && shared.candidateCount > 0) {
215
+ const fraction = Math.min(1, Math.max(0, explorationCfg.budgetFraction ?? 0.05));
216
+ explorationBudgetCount = Math.max(0, Math.floor(fraction * shared.candidateCount));
217
+ }
87
218
  return {
88
219
  phase,
89
220
  globalThreshold: shared.globalThreshold,
221
+ ...(phaseThreshold !== undefined ? { phaseThreshold } : {}),
90
222
  dryRun: shared.dryRun,
91
223
  stashDir: shared.stashDir,
92
224
  config: shared.config,
93
225
  eventsCtx: shared.eventsCtx,
226
+ ...(explorationBudgetCount !== undefined && explorationBudgetCount > 0 ? { explorationBudgetCount } : {}),
94
227
  ...overrides,
95
228
  };
96
229
  }