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
@@ -6,51 +6,33 @@ import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import readline from "node:readline";
8
8
  import { parse as yamlParse } from "yaml";
9
+ import consolidateSystemPrompt from "../../assets/prompts/consolidate-system.md" with { type: "text" };
9
10
  import { parseAssetRef } from "../../core/asset/asset-ref.js";
10
11
  import { assembleAssetFromString, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
11
12
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
12
13
  import { resolveStashDir, timestampForFilename } from "../../core/common.js";
13
14
  import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
14
15
  import { ConfigError } from "../../core/errors.js";
15
- import { appendEvent } from "../../core/events.js";
16
+ // Note: appendEvent import removed (WS-3a: archive TTL machinery retired)
16
17
  import { parseEmbeddedJsonResponse } from "../../core/parse.js";
17
18
  import { detectTruncatedDescription } from "../../core/text-truncation.js";
18
19
  import { hasHotCaptureMode, hasSupersededStatus, MERGE_ABSOLUTE_FLOOR_CHARS, MERGE_SHRINK_RATIO_MIN, validateProposalFrontmatter, } from "../proposal/validators/proposal-quality-validators.js";
19
20
  import { createProposal, isProposalSkipped, listProposals } from "../proposal/validators/proposals.js";
21
+ import { cacheHash, runDeterministicDedup, stripFrontmatterBody } from "./dedup.js";
22
+ import { checkGenerationGuard, checkLexicalDiversity, computeMergedGeneration, readAssetGeneration, runHomeostaticDemotion, shouldSkipHotProbationInLlm, } from "./homeostatic.js";
20
23
  import { writeContradictEdge } from "./memory/memory-belief.js";
21
24
  // Re-export the moved helpers so existing test imports continue to resolve.
22
25
  export { hasSupersededStatus, validateProposalFrontmatter };
26
+ import { getBodyEmbeddings, getConsolidationJudgedMap, openStateDatabase, upsertBodyEmbeddings, upsertConsolidationJudged, } from "../../core/state-db.js";
23
27
  import { warn } from "../../core/warn.js";
24
28
  import { commitWriteTargetBoundary, deleteAssetFromSource, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
25
29
  import { closeDatabase, findEntryIdByRef, getAllEntries, getEntryById, getNeighborsByEntryId, openExistingDatabase, } from "../../indexer/db/db.js";
26
30
  import { resolveImproveProcessRunnerFromProfile, runnerIsLlm } from "../../integrations/agent/runner.js";
27
31
  import { chatCompletion } from "../../llm/client.js";
28
- import { cosineSimilarity, embedBatch } from "../../llm/embedder.js";
32
+ import { cosineSimilarity, embedBatch, resolveEmbeddingModelId } from "../../llm/embedder.js";
29
33
  import { isLlmFeatureEnabled, tryLlmFeature } from "../../llm/feature-gate.js";
30
34
  // ── Prompts ─────────────────────────────────────────────────────────────────
31
- const CONSOLIDATE_SYSTEM_PROMPT = `You are the akm consolidate assistant analyzing memory assets.
32
-
33
- Rules:
34
- 1. MERGE: Two or more memories are substantially duplicated or closely related → propose merging. Return the primary ref to keep and secondary refs to delete. Do NOT include mergedContent — the merge will be executed in a separate step.
35
- 2. DELETE: Memory is clearly outdated, contradicted, or redundant → propose deletion. NEVER propose delete for memories annotated \`(captureMode: hot)\` — they are user-explicit and only the user can retire them. The downstream guard will refuse these regardless, so proposing them just wastes tokens.
36
- 3. PROMOTE: Memory expresses a stable, reusable fact suitable as a \`knowledge:\` asset → propose promotion. Do NOT delete the source memory. NEVER propose promote / merge / contradict for memories annotated \`(already queued)\` — they have a pending proposal whose body matches; a duplicate will be deterministically dropped, so proposing them just wastes tokens.
37
- 4. CONTRADICT: Two memories make mutually exclusive factual claims about the same subject (e.g. "always use VPN" vs "VPN is optional") → mark the older or less authoritative one as contradicted. This writes a contradictedBy edge so the belief-resolution SCC algorithm can resolve the conflict. Do NOT delete contradicted memories — let the belief resolver decide.
38
- 5. KEEP: Memory is unique and current → omit from output.
39
-
40
- Return ONLY JSON (no prose, no code fences):
41
- {
42
- "operations": [
43
- { "op": "merge", "primary": "memory:<name>", "secondaries": ["memory:<name>", ...], "mergeStrategy": "synthesize", "confidence": 0.95 },
44
- { "op": "delete", "ref": "memory:<name>", "reason": "<brief reason>", "confidence": 0.90 },
45
- { "op": "promote", "ref": "memory:<name>", "knowledgeRef": "knowledge:<suggested-slug>", "reason": "<brief reason>", "description": "<one sentence describing the new knowledge asset>", "confidence": 0.92 },
46
- { "op": "contradict", "ref": "memory:<name>", "contradictedByRef": "memory:<name>", "reason": "<brief reason>", "confidence": 0.88 }
47
- ],
48
- "warnings": ["<optional concerns>"]
49
- }
50
-
51
- For every operation, emit a \`confidence\` field in [0, 1] expressing your certainty that the operation is correct and safe. Use 0.95+ only when evidence is unambiguous. Omit the field rather than guessing if you are uncertain.
52
-
53
- When the merged content includes an \`updated\` frontmatter field, the value MUST be a real ISO date string (e.g. \`updated: 2026-05-20\`). NEVER emit \`updated: today\`, \`updated: {today}\`, \`updated: {today: null}\`, \`updated: now\`, or any other literal placeholder/template-variable. If you do not have a real source-of-truth date, OMIT the \`updated\` field entirely — the post-processor will not invent one for you.`;
35
+ const CONSOLIDATE_SYSTEM_PROMPT = consolidateSystemPrompt;
54
36
  /**
55
37
  * JSON Schema for structured consolidate plans (PR 1 of the asset-writers
56
38
  * decision — see knowledge:projects/akm/asset-writers-investigation/00-synthesis).
@@ -247,29 +229,17 @@ export function computeSafeChunkSize(contextLength, bodyTruncation, maxChunkSize
247
229
  const raw = Math.floor(usableTokens / tokensPerMemory);
248
230
  return Math.max(1, Math.min(maxChunkSize ?? 50, raw));
249
231
  }
250
- // ── Similarity clustering (C-1 / #380) ──────────────────────────────────────
251
- /**
252
- * Re-order memories so that similar ones are placed adjacent to each other
253
- * before the memories are sliced into chunks. This ensures high-similarity
254
- * memories land in the same LLM context window, allowing the consolidate
255
- * model to detect and merge duplicates that would otherwise be split across
256
- * chunks and survive indefinitely.
257
- *
258
- * Algorithm: greedy nearest-neighbour chain starting from the first memory.
259
- * Each step selects the unused memory with the highest cosine similarity to
260
- * the last-placed memory. O(n²) — acceptable for the expected N < 200.
261
- *
262
- * mem0 arXiv:2504.19413 — every candidate compared against whole store.
263
- * A-MEM arXiv:2502.12110 — atomic notes linked by similarity.
264
- *
265
- * Returns the original order unchanged when:
266
- * - The embedding config is not present.
267
- * - Embedding requests fail (fail-open).
268
- * - There are fewer than 3 memories (no benefit to reordering).
269
- */
270
- async function clusterMemoriesBySimilarity(memories, config) {
232
+ async function clusterMemoriesBySimilarity(memories, config, stateDb) {
233
+ const noTelemetry = { embedMs: 0, cacheHits: 0, cacheMisses: 0 };
271
234
  if (memories.length < 3 || !config.embedding)
272
- return memories;
235
+ return { ordered: memories, embedTelemetry: noTelemetry };
236
+ // WS-3a: cluster uses description+tags as the embedding input (NOT the raw
237
+ // body) — this is intentionally different from the dedup/body cache because
238
+ // the clustering goal is semantic grouping, not dedup twin detection.
239
+ // The body_embeddings cache is keyed by cacheHash(body); clustering inputs
240
+ // are keyed by cacheHash(description+tags text). Re-use the same table with
241
+ // a distinct hash so the two lookup sets never collide.
242
+ const modelId = resolveEmbeddingModelId(config.embedding);
273
243
  const texts = memories.map((m) => {
274
244
  const parts = [];
275
245
  if (m.description)
@@ -278,16 +248,91 @@ async function clusterMemoriesBySimilarity(memories, config) {
278
248
  parts.push(m.tags.join(" "));
279
249
  return parts.join(". ") || m.name;
280
250
  });
281
- let embeddings = null;
282
- try {
283
- embeddings = await embedBatch(texts, config.embedding);
251
+ // Compute content hashes for the cluster texts (not bodies — different input).
252
+ const contentHashes = texts.map((t) => createHash("sha256").update(t, "utf8").digest("hex"));
253
+ // WS-5: track embed cache hits/misses for perf telemetry.
254
+ let embedMs = 0;
255
+ let cacheHits = 0;
256
+ let cacheMisses = 0;
257
+ let cachedVecs = new Map();
258
+ if (stateDb) {
259
+ try {
260
+ cachedVecs = getBodyEmbeddings(stateDb, contentHashes, modelId);
261
+ }
262
+ catch {
263
+ // Fail open.
264
+ cachedVecs = new Map();
265
+ }
284
266
  }
285
- catch {
286
- // Fail open: embedding failures degrade gracefully to original order.
287
- return memories;
267
+ const missIndices = [];
268
+ const missTexts = [];
269
+ for (let i = 0; i < texts.length; i++) {
270
+ if (!cachedVecs.has(contentHashes[i])) {
271
+ missIndices.push(i);
272
+ missTexts.push(texts[i]);
273
+ cacheMisses++;
274
+ }
275
+ else {
276
+ cacheHits++;
277
+ }
288
278
  }
279
+ let missVecs = [];
280
+ if (missTexts.length > 0) {
281
+ const embedStart = Date.now();
282
+ try {
283
+ missVecs = await embedBatch(missTexts, config.embedding);
284
+ }
285
+ catch {
286
+ // Fail open: embedding failures degrade gracefully to original order.
287
+ return { ordered: memories, embedTelemetry: { embedMs, cacheHits, cacheMisses } };
288
+ }
289
+ finally {
290
+ embedMs += Date.now() - embedStart;
291
+ }
292
+ // Upsert newly computed vectors into the cache.
293
+ if (stateDb && missVecs.length === missTexts.length) {
294
+ try {
295
+ const toUpsert = missIndices.map((idx, pos) => ({
296
+ contentHash: contentHashes[idx],
297
+ embedding: missVecs[pos],
298
+ modelId,
299
+ }));
300
+ upsertBodyEmbeddings(stateDb, toUpsert);
301
+ }
302
+ catch {
303
+ // Fail open: cache write errors are non-fatal.
304
+ }
305
+ }
306
+ }
307
+ // Assemble the full embedding array in memories order.
308
+ let embeddings = null;
309
+ {
310
+ const assembled = [];
311
+ let ok = true;
312
+ for (let i = 0; i < memories.length; i++) {
313
+ const hash = contentHashes[i];
314
+ const cached = cachedVecs.get(hash);
315
+ if (cached) {
316
+ assembled.push(cached);
317
+ continue;
318
+ }
319
+ const missPos = missIndices.indexOf(i);
320
+ const vec = missPos >= 0 ? missVecs[missPos] : undefined;
321
+ if (vec) {
322
+ assembled.push(vec);
323
+ }
324
+ else {
325
+ ok = false;
326
+ break;
327
+ }
328
+ }
329
+ if (ok && assembled.length === memories.length) {
330
+ embeddings = assembled;
331
+ }
332
+ }
333
+ const embedTelemetry = { embedMs, cacheHits, cacheMisses };
289
334
  if (!embeddings || embeddings.length !== memories.length)
290
- return memories;
335
+ return { ordered: memories, embedTelemetry };
291
336
  // Greedy nearest-neighbour chain.
292
337
  const used = new Array(memories.length).fill(false);
293
338
  const ordered = [];
@@ -313,7 +358,7 @@ async function clusterMemoriesBySimilarity(memories, config) {
313
358
  used[bestIdx] = true;
314
359
  current = bestIdx;
315
360
  }
316
- return ordered;
361
+ return { ordered, embedTelemetry };
317
362
  }
318
363
  // ── Chunk helpers ────────────────────────────────────────────────────────────
319
364
  /**
@@ -347,7 +392,9 @@ export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks,
347
392
  }
348
393
  const parsed = parseFrontmatter(body);
349
394
  const isHot = parsed.data.captureMode === "hot";
350
- const bodyHash = createHash("sha256").update(parsed.content.trim(), "utf8").digest("hex");
395
+ // Use cacheHash (case-preserving stripped body) to match the domain used
396
+ // by loadPendingConsolidateProposalHashes and the body-embedding cache.
397
+ const bodyHash = cacheHash(body);
351
398
  const isAlreadyQueued = pendingProposalBodyHashes.has(bodyHash);
352
399
  annotationsByIndex.push({ isHot, isAlreadyQueued, body });
353
400
  if (isHot)
@@ -390,10 +437,10 @@ export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks,
390
437
  /**
391
438
  * Precompute body-hashes of all currently-pending consolidate proposals so
392
439
  * the per-chunk prompt can annotate memories whose body would just produce
393
- * a deterministic `dedup_pending_proposal` skip. Hash domain matches the
394
- * dedup site at ~line 1510 (sha256 over the post-frontmatter content,
395
- * trimmed). Empty set on any read/parse error — fail-safe to "annotate
396
- * nothing" so the LLM still proposes, just slightly more wastefully.
440
+ * a deterministic `dedup_pending_proposal` skip. Uses `cacheHash` (case-
441
+ * preserving stripped body) the same domain used by the body-embedding
442
+ * cache and `computeMemoryContentHash`. Empty set on any read/parse error
443
+ * — fail-safe to "annotate nothing" so the LLM still proposes.
397
444
  */
398
445
  function loadPendingConsolidateProposalHashes(stashDir) {
399
446
  const hashes = new Set();
@@ -401,8 +448,7 @@ function loadPendingConsolidateProposalHashes(stashDir) {
401
448
  const pending = listProposals(stashDir, { status: "pending" }).filter((p) => p.source === "consolidate");
402
449
  for (const p of pending) {
403
450
  try {
404
- const body = parseFrontmatter(p.payload.content).content.trim();
405
- hashes.add(createHash("sha256").update(body, "utf8").digest("hex"));
451
+ hashes.add(cacheHash(p.payload.content));
406
452
  }
407
453
  catch {
408
454
  // skip malformed payloads — they can't dedup anyway
@@ -672,6 +718,28 @@ function backupFile(filePath, backupDir, name) {
672
718
  // best-effort
673
719
  }
674
720
  }
721
+ // ── WS-3b: Generation frontmatter injection ───────────────────────────────────
722
+ /**
723
+ * Inject `generation` (and optionally `source_refs`) into merged content.
724
+ * generation = max(sourceGenerations) + 1.
725
+ * Fails open — returns original content if frontmatter can't be parsed.
726
+ */
727
+ function injectGenerationFrontmatter(mergedContent, sourceGenerations, allParticipants) {
728
+ try {
729
+ const parsed = parseFrontmatter(mergedContent);
730
+ const updatedFm = {
731
+ ...parsed.data,
732
+ generation: computeMergedGeneration(sourceGenerations),
733
+ };
734
+ if (!updatedFm.source_refs) {
735
+ updatedFm.source_refs = allParticipants;
736
+ }
737
+ return assembleAssetFromString(serializeFrontmatter(updatedFm), parsed.content);
738
+ }
739
+ catch {
740
+ return mergedContent; // fail open
741
+ }
742
+ }
675
743
  // ── Archive helper (P1-B: soft-invalidation) ─────────────────────────────────
676
744
  /**
677
745
  * Move a memory asset to `.akm/archive/` with `status: superseded` frontmatter
@@ -752,6 +820,25 @@ function resolveConsolidateLlmConfig(config) {
752
820
  // fall back to the default LLM profile rather than disabling the pass.
753
821
  return getDefaultLlmConfig(config);
754
822
  }
823
+ // ── Judged-state cache (#581) ────────────────────────────────────────────────
824
+ /**
825
+ * Stable content hash for a memory file used by the judged-state cache (#581)
826
+ * and the body-embedding cache (WS-3a). Uses `cacheHash` from dedup.ts:
827
+ * sha256 of the case-preserving stripped body. Two memories that differ only
828
+ * in frontmatter (`updated:`, `inferenceProcessed:`) hash identically, so a
829
+ * cosmetic frontmatter touch never forces a needless re-judge — only a body
830
+ * change does. Returns `undefined` on any read/parse error so callers fail
831
+ * open (treat the memory as un-cached → it stays in the LLM pool).
832
+ */
833
+ function computeMemoryContentHash(filePath) {
834
+ try {
835
+ const raw = fs.readFileSync(filePath, "utf8");
836
+ return cacheHash(raw);
837
+ }
838
+ catch {
839
+ return undefined;
840
+ }
841
+ }
755
842
  // ── Main entry point ─────────────────────────────────────────────────────────
756
843
  export async function akmConsolidate(opts = {}) {
757
844
  const startMs = Date.now();
@@ -780,6 +867,27 @@ export async function akmConsolidate(opts = {}) {
780
867
  }
781
868
  const warnings = [];
782
869
  checkForIncompleteJournal(stashDir, opts.recoveryMode ?? "abort", warnings);
870
+ // WS-3a: open one state.db handle shared by the body-embedding cache (dedup
871
+ // + cluster) and the judged-state cache. All callers in the function body
872
+ // receive this handle; it is closed in the `finally` block below.
873
+ // Fail-open: any open error leaves it `undefined` and all cache paths skip.
874
+ let sharedStateDb;
875
+ try {
876
+ sharedStateDb = openStateDatabase();
877
+ }
878
+ catch {
879
+ // State DB unavailable → skip the embedding cache for this run.
880
+ }
881
+ try {
882
+ return await akmConsolidateInner(opts, config, stashDir, startMs, sourceRun, warnings, sharedStateDb);
883
+ }
884
+ finally {
885
+ sharedStateDb?.close();
886
+ }
887
+ }
888
+ // Inner implementation — all early-return paths are here; sharedStateDb is
889
+ // closed by the outer finally in `akmConsolidate`.
890
+ async function akmConsolidateInner(opts, config, stashDir, startMs, sourceRun, warnings, sharedStateDb) {
783
891
  let memories = loadMemoriesForSource(opts.target, stashDir, warnings);
784
892
  // Pre-flight: filter out stale DB entries whose files no longer exist on
785
893
  // disk. Without this, memories deleted by a prior run (but not yet
@@ -791,6 +899,80 @@ export async function akmConsolidate(opts = {}) {
791
899
  warnings.push(`Pre-flight: filtered ${staleCount} stale DB entr${staleCount === 1 ? "y" : "ies"} (file absent on disk) from memory pool before chunking.`);
792
900
  }
793
901
  memories = memories.filter((m) => fs.existsSync(m.filePath));
902
+ // ── WS-3b Step 0a: Homeostatic demotion ────────────────────────────────────
903
+ // DEFAULT OFF. Before any LLM merge, demote retrievalSalience in state.db
904
+ // for stale/low-value assets so the merge pool is bounded and high-SNR.
905
+ // Demotion is state.db-only (file content untouched); re-promotable on
906
+ // re-retrieval. Only fires when `homeostaticDemotion.enabled === true`.
907
+ const homeostaticConfig = config.profiles?.improve?.default?.processes?.consolidate?.homeostaticDemotion ?? {};
908
+ if (homeostaticConfig.enabled && sharedStateDb) {
909
+ const demotionResult = runHomeostaticDemotion(sharedStateDb, homeostaticConfig);
910
+ if (demotionResult.demoted > 0) {
911
+ warnings.push(`Homeostatic demotion: demoted retrievalSalience for ${demotionResult.demoted} stale asset(s) before merge pool assembly.`);
912
+ }
913
+ warnings.push(...demotionResult.warnings);
914
+ }
915
+ // ── WS-3b Step 0c: Filter hot-probation assets from LLM merge pool ─────────
916
+ // Hot-probation assets (system-generated, not yet graduated from intake pass)
917
+ // are processed by the dedup pre-pass but excluded from the LLM clustering.
918
+ // This prevents noisy extractions from polluting LLM context. The dedup pass
919
+ // below still runs against them so they're cleaned up deterministically.
920
+ // DEFAULT OFF — only active when `processes.extract.hotProbation.enabled === true`
921
+ // (the flag that causes extract to tag new extractions as hot-probation).
922
+ // Without that flag no assets will ever carry the hot-probation marker, so
923
+ // running the filter loop would be pure unnecessary I/O over the full corpus.
924
+ const hotProbationEnabled = config.profiles?.improve?.default?.processes?.extract?.hotProbation
925
+ ?.enabled === true;
926
+ let hotProbationCount = 0;
927
+ if (hotProbationEnabled) {
928
+ const hotProbationMemories = [];
929
+ const nonProbationMemories = [];
930
+ for (const m of memories) {
931
+ try {
932
+ const raw = fs.readFileSync(m.filePath, "utf8");
933
+ const parsed = parseFrontmatter(raw);
934
+ if (shouldSkipHotProbationInLlm(parsed.data)) {
935
+ hotProbationMemories.push(m);
936
+ hotProbationCount++;
937
+ }
938
+ else {
939
+ nonProbationMemories.push(m);
940
+ }
941
+ }
942
+ catch {
943
+ nonProbationMemories.push(m); // fail open
944
+ }
945
+ }
946
+ if (hotProbationCount > 0) {
947
+ warnings.push(`Hot-probation: ${hotProbationCount} hot-probation asset(s) routed to dedup-only pass (excluded from LLM merge pool).`);
948
+ memories = nonProbationMemories;
949
+ }
950
+ }
951
+ // ── Deterministic dedup pre-pass (#617) ─────────────────────────────────────
952
+ // Cheap, no-LLM fast path that collapses the obvious near-duplicates
953
+ // (`.derived` ↔ origin pairs + content twins) BEFORE the embedding-clustered
954
+ // LLM consolidation. DEFAULT OFF — when `dedup.enabled !== true` this is a
955
+ // no-op and the pass behaves byte-identically to today. Collapsed variants
956
+ // are pruned from the LLM pool so the model only ever sees genuinely
957
+ // distinct-but-related memories. Each dropped variant is archived (soft
958
+ // invalidation) before deletion, matching the LLM merge path.
959
+ // Dry-run never mutates the filesystem, so the dedup pre-pass is skipped
960
+ // entirely under `--dry-run` (the LLM plan preview below is unaffected).
961
+ let dedupCollapsed = 0;
962
+ if (opts.dedup?.enabled && !opts.dryRun) {
963
+ const dedupTimestamp = timestampForFilename();
964
+ const dedupResult = await runDeterministicDedup(stashDir, opts.dedup, config, (variantFilePath, variantName) => {
965
+ archiveMemory(variantFilePath, stashDir, `memory:${variantName}`, "collapsed by deterministic dedup pre-pass", -1, undefined, warnings);
966
+ backupFile(variantFilePath, getBackupDir(stashDir, dedupTimestamp), variantName);
967
+ }, opts.signal, sharedStateDb);
968
+ dedupCollapsed = dedupResult.collapsed;
969
+ warnings.push(...dedupResult.warnings);
970
+ if (dedupResult.consumedRefs.length > 0) {
971
+ const consumed = new Set(dedupResult.consumedRefs);
972
+ memories = memories.filter((m) => !consumed.has(`memory:${m.name}`));
973
+ warnings.push(`Deterministic dedup: collapsed ${dedupResult.collapsed} near-duplicate memor${dedupResult.collapsed === 1 ? "y" : "ies"} (no LLM) before chunking.`);
974
+ }
975
+ }
794
976
  if (memories.length === 0) {
795
977
  return {
796
978
  schemaVersion: 1,
@@ -801,7 +983,10 @@ export async function akmConsolidate(opts = {}) {
801
983
  target: opts.target ?? stashDir,
802
984
  processed: 0,
803
985
  merged: 0,
804
- deleted: 0,
986
+ // #617: the deterministic dedup pre-pass may have emptied the pool by
987
+ // collapsing every remaining memory into a canonical. Surface those
988
+ // collapses in `deleted` so the run reports the work it actually did.
989
+ deleted: dedupCollapsed,
805
990
  promoted: [],
806
991
  contradicted: 0,
807
992
  warnings,
@@ -809,7 +994,7 @@ export async function akmConsolidate(opts = {}) {
809
994
  };
810
995
  }
811
996
  if (opts.incrementalSince) {
812
- memories = narrowToIncrementalCandidates(memories, opts.incrementalSince, warnings);
997
+ memories = narrowToIncrementalCandidates(memories, opts.incrementalSince, warnings, opts.neighborsPerChanged);
813
998
  if (memories.length === 0) {
814
999
  return {
815
1000
  schemaVersion: 1,
@@ -828,6 +1013,114 @@ export async function akmConsolidate(opts = {}) {
828
1013
  };
829
1014
  }
830
1015
  }
1016
+ // WS-5 perf telemetry accumulators. These are collected throughout the run and
1017
+ // merged into `perfTelemetry` on the final ConsolidateResult.
1018
+ // `dedupPoolSize` = memories entering judgedCache narrowing (after dedup+incremental+limit).
1019
+ // `judgedCacheSkipped` = memories skipped by the cache.
1020
+ // `llmPoolSize` = memories actually sent to the LLM.
1021
+ // `embedMs/cacheHits/cacheMisses` = accumulated from clusterMemoriesBySimilarity.
1022
+ const perfMs = { dedupPoolSize: memories.length, judgedCacheSkipped: 0 };
1023
+ // ── Judged-state cache narrowing (#581) ─────────────────────────────────────
1024
+ // DEFAULT OFF. When enabled, skip every memory whose current content hash
1025
+ // equals the hash recorded the last time the consolidate LLM judged it
1026
+ // (judged-unchanged → no re-judge). This converts coverage from O(window) to
1027
+ // O(changed/new) so one run can sweep the whole corpus while the LLM only
1028
+ // sees genuinely new/changed memories. `currentHashByName` is populated for
1029
+ // EVERY surviving memory (whether or not the cache is on) so the post-LLM
1030
+ // recording step can upsert judged state without re-reading the files; when
1031
+ // the cache is off it stays empty and the recording step is a no-op.
1032
+ const judgedCacheEnabled = opts.judgedCache?.enabled !== false;
1033
+ const currentHashByName = new Map();
1034
+ if (judgedCacheEnabled) {
1035
+ for (const m of memories) {
1036
+ const h = computeMemoryContentHash(m.filePath);
1037
+ if (h !== undefined)
1038
+ currentHashByName.set(m.name, h);
1039
+ }
1040
+ let cachedMap = new Map();
1041
+ {
1042
+ // Use the shared state.db handle if available; open a local one otherwise.
1043
+ const dbForJudged = sharedStateDb;
1044
+ if (dbForJudged) {
1045
+ try {
1046
+ cachedMap = getConsolidationJudgedMap(dbForJudged, memories.map((m) => `memory:${m.name}`));
1047
+ }
1048
+ catch {
1049
+ cachedMap = new Map();
1050
+ }
1051
+ }
1052
+ else {
1053
+ let localDb;
1054
+ try {
1055
+ localDb = openStateDatabase();
1056
+ cachedMap = getConsolidationJudgedMap(localDb, memories.map((m) => `memory:${m.name}`));
1057
+ }
1058
+ catch {
1059
+ // State DB unavailable → fail open: judge the full pool this run.
1060
+ cachedMap = new Map();
1061
+ }
1062
+ finally {
1063
+ localDb?.close();
1064
+ }
1065
+ }
1066
+ }
1067
+ const beforeCount = memories.length;
1068
+ memories = memories.filter((m) => {
1069
+ const cur = currentHashByName.get(m.name);
1070
+ // No readable hash → keep (fail open; let the LLM judge it).
1071
+ if (cur === undefined)
1072
+ return true;
1073
+ const cached = cachedMap.get(`memory:${m.name}`);
1074
+ // Skip only when previously judged AND content is byte-identical since.
1075
+ return !(cached !== undefined && cached.content_hash === cur);
1076
+ });
1077
+ const skipped = beforeCount - memories.length;
1078
+ perfMs.judgedCacheSkipped = skipped; // WS-5 perf telemetry
1079
+ if (skipped > 0) {
1080
+ warnings.push(`Judged-state cache: skipped ${skipped} memor${skipped === 1 ? "y" : "ies"} judged-unchanged (no LLM); ${memories.length} remain for judging.`);
1081
+ }
1082
+ if (memories.length === 0) {
1083
+ return {
1084
+ schemaVersion: 1,
1085
+ ok: true,
1086
+ shape: "consolidate-result",
1087
+ dryRun: opts.dryRun ?? false,
1088
+ previewOnly: false,
1089
+ target: opts.target ?? stashDir,
1090
+ processed: 0,
1091
+ merged: 0,
1092
+ deleted: dedupCollapsed,
1093
+ promoted: [],
1094
+ contradicted: 0,
1095
+ warnings,
1096
+ durationMs: Date.now() - startMs,
1097
+ };
1098
+ }
1099
+ }
1100
+ if (opts.limit === undefined && memories.length > 150) {
1101
+ warnings.push(`Consolidation: pool has ${memories.length} memories and no limit is set. Consider adding a limit to your consolidate config to prevent timeouts on slow LLM endpoints.`);
1102
+ }
1103
+ if (opts.limit !== undefined && memories.length > opts.limit) {
1104
+ // Order oldest-modified-first before capping so the limit selects the
1105
+ // stalest memories rather than a fixed head of the (rowid-ordered) DB
1106
+ // query. Consolidation rewrites surviving files, bumping their mtime, so
1107
+ // processed memories drift to the back of the queue and the cap rotates
1108
+ // across the whole corpus over successive runs instead of revisiting the
1109
+ // same slice every time. Fail-open to 0 (front of queue) when a file can
1110
+ // no longer be stat'd.
1111
+ const mtimeOf = (m) => {
1112
+ try {
1113
+ return fs.statSync(m.filePath).mtimeMs;
1114
+ }
1115
+ catch {
1116
+ return 0;
1117
+ }
1118
+ };
1119
+ const mtimeCache = new Map(memories.map((m) => [m.filePath, mtimeOf(m)]));
1120
+ memories = [...memories].sort((a, b) => (mtimeCache.get(a.filePath) ?? 0) - (mtimeCache.get(b.filePath) ?? 0));
1121
+ warnings.push(`Consolidation: pool capped at ${opts.limit} of ${memories.length} memories (limit option, oldest-modified first).`);
1122
+ memories = memories.slice(0, opts.limit);
1123
+ }
831
1124
  // Consolidation always uses the HTTP LLM client directly — never the agent
832
1125
  // CLI. The agent CLI is for interactive agent sessions (reflect, propose);
833
1126
  // structured JSON generation works better and faster via HTTP.
@@ -851,16 +1144,64 @@ export async function akmConsolidate(opts = {}) {
851
1144
  const chunkSize = computeSafeChunkSize(modelContextLength, bodyTruncation, opts.maxChunkSize);
852
1145
  // -- Phase A: plan generation -----------------------------------------------
853
1146
  const sourceName = opts.target ?? stashDir;
1147
+ // WS-5: capture llmPoolSize = memories entering the LLM (after all filtering).
1148
+ const llmPoolSize = memories.length;
854
1149
  // C-1 / #380: Pre-cluster memories by embedding similarity before chunking.
855
1150
  // This ensures that semantically similar memories land in the same LLM
856
1151
  // context window, allowing the model to detect and merge duplicates that
857
1152
  // would otherwise be split across chunks and survive indefinitely.
858
1153
  // mem0 arXiv:2504.19413, A-MEM arXiv:2502.12110.
859
1154
  // Fails open: if embeddings are unavailable or fail, original order is used.
860
- const clusteredMemories = await clusterMemoriesBySimilarity(memories, config);
1155
+ const { ordered: clusteredMemories, embedTelemetry } = await clusterMemoriesBySimilarity(memories, config, sharedStateDb);
1156
+ // WS-3b Anti-collapse step 8c: inject random (non-similar) clusters.
1157
+ // A small fraction (default 5%) of the pool is shuffled into random positions
1158
+ // so the pipeline isn't PURELY similarity-driven. This prevents rich-get-richer
1159
+ // entrenchment where only the most-retrieved assets ever get consolidated.
1160
+ // DEFAULT OFF — gated on antiCollapse.enabled.
1161
+ let finalClusteredMemories = clusteredMemories;
1162
+ {
1163
+ const antiCollapseForCluster = config.profiles?.improve?.default?.processes?.consolidate?.antiCollapse ?? {};
1164
+ if (antiCollapseForCluster.enabled && clusteredMemories.length > 2) {
1165
+ const fraction = antiCollapseForCluster.randomClusterFraction ?? 0.05;
1166
+ const randomCount = Math.max(1, Math.floor(clusteredMemories.length * fraction));
1167
+ // Pick `randomCount` positions to inject random (un-clustered) members.
1168
+ // Use a seeded-ish shuffle: sort by hash of the name so it's deterministic
1169
+ // per run but not strictly similarity-driven.
1170
+ const shuffled = [...clusteredMemories].sort((a, b) => {
1171
+ // Deterministic shuffle: compare sha256-ish (use name hash as proxy).
1172
+ const ha = a.name.split("").reduce((acc, c) => ((acc << 5) - acc + c.charCodeAt(0)) | 0, 0);
1173
+ const hb = b.name.split("").reduce((acc, c) => ((acc << 5) - acc + c.charCodeAt(0)) | 0, 0);
1174
+ return ha - hb;
1175
+ });
1176
+ const randomSlice = shuffled.slice(0, randomCount);
1177
+ const randomSet = new Set(randomSlice.map((m) => m.name));
1178
+ // Insert random members at intervals through the clustered sequence.
1179
+ const withRandom = [];
1180
+ const interval = Math.max(2, Math.floor(clusteredMemories.length / randomCount));
1181
+ let randomIdx = 0;
1182
+ for (let i = 0; i < clusteredMemories.length; i++) {
1183
+ const m = clusteredMemories[i];
1184
+ if (m && !randomSet.has(m.name))
1185
+ withRandom.push(m);
1186
+ if (i > 0 && i % interval === 0 && randomIdx < randomSlice.length) {
1187
+ const r = randomSlice[randomIdx++];
1188
+ if (r)
1189
+ withRandom.push(r);
1190
+ }
1191
+ }
1192
+ // Append any remaining random members not yet inserted.
1193
+ while (randomIdx < randomSlice.length) {
1194
+ const r = randomSlice[randomIdx++];
1195
+ if (r)
1196
+ withRandom.push(r);
1197
+ }
1198
+ finalClusteredMemories = withRandom;
1199
+ warnings.push(`Anti-collapse: injected ${randomCount} random (non-similarity-driven) cluster member(s) into consolidation pool (fraction=${fraction}).`);
1200
+ }
1201
+ }
861
1202
  const chunks = [];
862
- for (let i = 0; i < clusteredMemories.length; i += chunkSize) {
863
- chunks.push(clusteredMemories.slice(i, i + chunkSize));
1203
+ for (let i = 0; i < finalClusteredMemories.length; i += chunkSize) {
1204
+ chunks.push(finalClusteredMemories.slice(i, i + chunkSize));
864
1205
  }
865
1206
  // 2026-05-27 prompt-context fix: precompute body-hashes of pending
866
1207
  // consolidate proposals once, so the per-chunk prompt can annotate
@@ -869,6 +1210,40 @@ export async function akmConsolidate(opts = {}) {
869
1210
  // 4h on this user's stack. See
870
1211
  // /tmp/akm-health-investigations/tuning-reasons-investigation.md §Q3.
871
1212
  const pendingProposalBodyHashes = loadPendingConsolidateProposalHashes(stashDir);
1213
+ // ── Cold-start budget estimation ─────────────────────────────────────────────
1214
+ // Estimate wall-clock cost BEFORE issuing any LLM calls. When a signal is
1215
+ // provided and the estimated cost exceeds ~60% of the remaining budget we
1216
+ // auto-reduce the pool and log the reduction so the run never starts work
1217
+ // it cannot finish (avoiding SIGTERM mid-LLM-call).
1218
+ //
1219
+ // Formula: chunks.length × p90_chunk_seconds. The p90 comes from
1220
+ // `opts.p90ChunkSecondsDefault` (caller-supplied, typically from the profile
1221
+ // config); absent = 30 s (conservative default matching a medium local LLM).
1222
+ //
1223
+ // "Remaining budget" is read from a custom property on the AbortSignal if
1224
+ // the caller (improve.ts) has attached one. Without it no auto-reduction
1225
+ // fires but the check is still cheap to run.
1226
+ if (chunks.length > 10 && opts.signal) {
1227
+ const p90Chunk = opts.p90ChunkSecondsDefault ?? 30;
1228
+ const estimatedSeconds = chunks.length * p90Chunk;
1229
+ // remainingBudgetMs is a non-standard extension set by improve.ts when it
1230
+ // creates the budget AbortController. Undefined = no budget information.
1231
+ const budgetMs = opts.signal.remainingBudgetMs;
1232
+ if (budgetMs !== undefined && budgetMs > 0) {
1233
+ const remainingSeconds = budgetMs / 1000;
1234
+ if (estimatedSeconds > remainingSeconds * 0.6) {
1235
+ const safeCaps = Math.max(1, Math.floor((remainingSeconds * 0.6) / p90Chunk));
1236
+ const removedChunks = chunks.length - safeCaps;
1237
+ if (removedChunks > 0) {
1238
+ const msg = `[consolidate] cold-start budget: estimated ${estimatedSeconds.toFixed(0)}s > 60% of remaining ${remainingSeconds.toFixed(0)}s; ` +
1239
+ `reducing pool from ${chunks.length} to ${safeCaps} chunks (${removedChunks} deferred to next run).`;
1240
+ warn(msg);
1241
+ warnings.push(msg);
1242
+ chunks.splice(safeCaps);
1243
+ }
1244
+ }
1245
+ }
1246
+ }
872
1247
  warn(`[consolidate] ${memories.length} memories / ${chunks.length} chunk(s) / chunk_size=${chunkSize}` +
873
1248
  ` / pending-proposal hashes: ${pendingProposalBodyHashes.size}`);
874
1249
  const chunkOpsArrays = [];
@@ -907,6 +1282,13 @@ export async function akmConsolidate(opts = {}) {
907
1282
  // judgedNoAction tracks memories the LLM saw inside a chunk but proposed
908
1283
  // no op for. Computed per chunk as `chunk.length − unique(targetRefs in ops)`.
909
1284
  let judgedNoAction = 0;
1285
+ // Judged-state cache (#581): coarse outcome per memory NAME the LLM actually
1286
+ // judged in a successfully-parsed chunk this run. "actioned" = an op targeted
1287
+ // it; "no_action" = the LLM saw it and proposed nothing. Populated only when
1288
+ // the cache is enabled (otherwise it stays empty and the post-loop recording
1289
+ // step is a no-op). Memories in failed/aborted chunks are NOT recorded, so a
1290
+ // transient LLM failure never poisons the cache into skipping them next run.
1291
+ const judgedOutcomeByName = new Map();
910
1292
  // 2026-05-27 cross-chunk double-count fix: refs that contributed to
911
1293
  // judgedNoAction in their own chunk. When a different chunk's op references
912
1294
  // one of these as a secondary and that op later fails, the ref would land
@@ -931,6 +1313,19 @@ export async function akmConsolidate(opts = {}) {
931
1313
  const ABORT_MIN_CHUNKS = 4;
932
1314
  const ABORT_FAILURE_RATE = 0.5;
933
1315
  for (let chunkIdx = 0; chunkIdx < chunks.length; chunkIdx++) {
1316
+ // Budget-signal check: break cleanly before the next LLM call if the
1317
+ // caller's budget has been exhausted. Commits work done so far.
1318
+ if (opts.signal?.aborted) {
1319
+ const skipped = chunks.length - chunkIdx;
1320
+ const msg = `[consolidate] budget signal aborted before chunk ${chunkIdx + 1}/${chunks.length}; ${skipped} chunk(s) not processed (partial_timeout — work done so far committed).`;
1321
+ warn(msg);
1322
+ warnings.push(msg);
1323
+ // Account for memories in unprocessed chunks.
1324
+ for (let i = chunkIdx; i < chunks.length; i++) {
1325
+ failedChunkMemories += chunks[i].length;
1326
+ }
1327
+ break;
1328
+ }
934
1329
  // Abort if failure rate >= 50% over at least 4 processed chunks.
935
1330
  if (totalChunksProcessed >= ABORT_MIN_CHUNKS) {
936
1331
  const failureRate = totalChunksFailed / totalChunksProcessed;
@@ -1075,11 +1470,64 @@ export async function akmConsolidate(opts = {}) {
1075
1470
  if (!targetRefs.has(memRef)) {
1076
1471
  chunkNoAction++;
1077
1472
  judgedNoActionRefs.add(memRef);
1473
+ // Judged-state cache (#581): the LLM saw this memory and proposed
1474
+ // nothing → record judged-unchanged so the next run can skip it.
1475
+ if (judgedCacheEnabled)
1476
+ judgedOutcomeByName.set(m.name, "no_action");
1477
+ }
1478
+ else if (judgedCacheEnabled) {
1479
+ // An op targeted this memory → it was judged + actioned.
1480
+ judgedOutcomeByName.set(m.name, "actioned");
1078
1481
  }
1079
1482
  }
1080
1483
  judgedNoAction += chunkNoAction;
1081
1484
  chunkOpsArrays.push(ops);
1082
1485
  }
1486
+ // ── Judged-state cache recording (#581) ─────────────────────────────────────
1487
+ // Persist judged state for every memory the LLM actually judged this run so
1488
+ // the next run can skip the unchanged ones. Keyed by current content hash so
1489
+ // a later body edit (different hash) re-enters the LLM pool. DEFAULT OFF and
1490
+ // skipped under --dry-run (dry-run mutates nothing). Failed/aborted chunks
1491
+ // contributed no entries to `judgedOutcomeByName`, so a transient LLM outage
1492
+ // never caches a memory as judged.
1493
+ if (judgedCacheEnabled && !opts.dryRun && judgedOutcomeByName.size > 0) {
1494
+ // Use the shared state.db handle; open a local one as fallback.
1495
+ const doRecord = (db) => {
1496
+ const judgedAt = new Date(startMs).toISOString();
1497
+ for (const [name, outcome] of judgedOutcomeByName) {
1498
+ const hash = currentHashByName.get(name);
1499
+ if (hash === undefined)
1500
+ continue;
1501
+ upsertConsolidationJudged(db, {
1502
+ entryKey: `memory:${name}`,
1503
+ contentHash: hash,
1504
+ judgedAt,
1505
+ outcome,
1506
+ });
1507
+ }
1508
+ };
1509
+ if (sharedStateDb) {
1510
+ try {
1511
+ doRecord(sharedStateDb);
1512
+ }
1513
+ catch (e) {
1514
+ warnings.push(`Judged-state cache: failed to record judged state: ${String(e)}`);
1515
+ }
1516
+ }
1517
+ else {
1518
+ let localDb;
1519
+ try {
1520
+ localDb = openStateDatabase();
1521
+ doRecord(localDb);
1522
+ }
1523
+ catch (e) {
1524
+ warnings.push(`Judged-state cache: failed to record judged state: ${String(e)}`);
1525
+ }
1526
+ finally {
1527
+ localDb?.close();
1528
+ }
1529
+ }
1530
+ }
1083
1531
  // Build the known-refs set from the already-filtered memory pool so
1084
1532
  // mergePlans() can reject LLM-hallucinated primary refs before execution.
1085
1533
  const knownRefs = new Set(memories.map((m) => `memory:${m.name}`));
@@ -1266,7 +1714,7 @@ export async function akmConsolidate(opts = {}) {
1266
1714
  emitMergeFailureSkips(mergeResult.error);
1267
1715
  continue;
1268
1716
  }
1269
- const mergedContent = mergeResult.content;
1717
+ let mergedContent = mergeResult.content;
1270
1718
  // Validate frontmatter of merged content — must have a `---` block
1271
1719
  // with at minimum a `description` field. We parse via the hand-rolled
1272
1720
  // parser (cheap) AND require non-empty description. This guards against
@@ -1320,6 +1768,62 @@ export async function akmConsolidate(opts = {}) {
1320
1768
  emitMergeFailureSkips("merge_participant_blocked");
1321
1769
  continue;
1322
1770
  }
1771
+ // WS-3b: Anti-collapse generation guard (step 8a).
1772
+ // DEFAULT OFF. When antiCollapse.enabled, refuse to merge two assets both
1773
+ // above generation N (default 2). This prevents the pipeline from
1774
+ // building ever-deeper LLM-merged trees that lose the source fidelity
1775
+ // of the original episodes.
1776
+ const antiCollapseConfig = config.profiles?.improve?.default?.processes?.consolidate?.antiCollapse ??
1777
+ {};
1778
+ if (antiCollapseConfig.enabled) {
1779
+ const allParticipants = [op.primary, ...op.secondaries];
1780
+ const sourceGenerations = allParticipants.map((ref) => {
1781
+ const e = memoryByRef.get(ref);
1782
+ if (!e)
1783
+ return 0;
1784
+ try {
1785
+ const raw = fs.readFileSync(e.filePath, "utf8");
1786
+ const parsed = parseFrontmatter(raw);
1787
+ return readAssetGeneration(parsed.data);
1788
+ }
1789
+ catch {
1790
+ return 0;
1791
+ }
1792
+ });
1793
+ const generationCheck = checkGenerationGuard(sourceGenerations, antiCollapseConfig);
1794
+ if (generationCheck.refused) {
1795
+ warnings.push(`Merge: ${generationCheck.reason}`);
1796
+ emitMergeFailureSkips("merge_generation_guard");
1797
+ continue;
1798
+ }
1799
+ // WS-3b: Lexical diversity check (step 8b).
1800
+ // Low n-gram diversity ⇒ likely correlated-extraction artifact; raise merge threshold.
1801
+ if (antiCollapseConfig.lexicalDiversityCheck !== false) {
1802
+ const bodies = allParticipants
1803
+ .map((ref) => {
1804
+ const e = memoryByRef.get(ref);
1805
+ if (!e)
1806
+ return "";
1807
+ try {
1808
+ const raw = fs.readFileSync(e.filePath, "utf8");
1809
+ return stripFrontmatterBody(raw);
1810
+ }
1811
+ catch {
1812
+ return "";
1813
+ }
1814
+ })
1815
+ .filter((b) => b.length > 0);
1816
+ const diversityCheck = checkLexicalDiversity(bodies, antiCollapseConfig);
1817
+ if (diversityCheck.lowDiversity) {
1818
+ // Low-diversity cluster: just warn (don't refuse merge since the dedup
1819
+ // path handles exact twins). The warning surfaces in health telemetry.
1820
+ warnings.push(`Merge: cluster around ${op.primary} has low lexical diversity (${diversityCheck.diversity?.toFixed(2) ?? "?"} < 0.30) — likely correlated extraction; merge proceeds but review is recommended.`);
1821
+ }
1822
+ }
1823
+ // Inject generation counter into merged content frontmatter (step 8a).
1824
+ // merged.generation = max(sourceGenerations) + 1.
1825
+ mergedContent = injectGenerationFrontmatter(mergedContent, sourceGenerations, allParticipants);
1826
+ }
1323
1827
  // Backup secondaries before deleting
1324
1828
  for (const secRef of op.secondaries) {
1325
1829
  const secEntry = memoryByRef.get(secRef);
@@ -1522,11 +2026,12 @@ export async function akmConsolidate(opts = {}) {
1522
2026
  // is the load-bearing content, so dedup must hash on body only.
1523
2027
  // (b) A prior run created a proposal for the same body under a
1524
2028
  // different knowledgeRef slug.
1525
- const bodyHash = createHash("sha256").update(sourceBody, "utf8").digest("hex");
2029
+ // Use cacheHash (case-preserving stripped body) to match the canonical
2030
+ // hash domain used by the body-embedding cache and pending-proposal set.
2031
+ const bodyHash = cacheHash(sourceBody);
1526
2032
  const allPendingConsolidateProposals = listProposals(stashDir, { status: "pending" }).filter((p) => p.source === "consolidate");
1527
2033
  const contentDupProposal = allPendingConsolidateProposals.find((p) => {
1528
- const otherBody = parseFrontmatter(p.payload.content).content.trim();
1529
- return createHash("sha256").update(otherBody, "utf8").digest("hex") === bodyHash;
2034
+ return cacheHash(p.payload.content) === bodyHash;
1530
2035
  });
1531
2036
  if (contentDupProposal) {
1532
2037
  warnings.push(`Skipping promote: identical body already pending as proposal ${contentDupProposal.id} (ref: ${contentDupProposal.ref}); skipping duplicate for ${op.ref} → ${knowledgeRef}`);
@@ -1608,6 +2113,18 @@ export async function akmConsolidate(opts = {}) {
1608
2113
  }
1609
2114
  }
1610
2115
  else if (op.op === "contradict") {
2116
+ // Confidence gate: surface-level topic overlap causes false positives
2117
+ // (investigation 2026-06-18). Require ≥0.92 confidence before writing
2118
+ // contradiction edges. Missing confidence field defaults to 1.0 for
2119
+ // backward compatibility with responses that predate this field.
2120
+ const opConfidence = typeof op.confidence === "number"
2121
+ ? op.confidence
2122
+ : 1.0;
2123
+ if (opConfidence < 0.92) {
2124
+ warnings.push(`Contradict: confidence ${opConfidence.toFixed(2)} below 0.92 threshold for ${op.ref} <-> ${op.contradictedByRef} — skipping.`);
2125
+ pushSkipReason("contradict", op.ref, "contradict_low_confidence");
2126
+ continue;
2127
+ }
1611
2128
  // C-3 / #382: Write contradictedBy edges so resolveFamilyContradictions
1612
2129
  // (the SCC resolver in memory-improve.ts) has edges to work on.
1613
2130
  // Zep arXiv:2501.13956 §3 — unified belief-revision with contradiction edges.
@@ -1647,37 +2164,19 @@ export async function akmConsolidate(opts = {}) {
1647
2164
  commitWriteTargetBoundary(target, `Consolidate: ${merged} merged, ${deleted} removed`);
1648
2165
  }
1649
2166
  cleanupJournal(stashDir, timestamp);
1650
- // TTL cleanup: remove archive entries older than archiveRetentionDays (default 90).
1651
- // C-5 / #391: emit an `archive_cleanup` event before each deletion so the
1652
- // audit trail records what was lost. Outbox pattern (EIP, Hohpe-Woolf)
1653
- // any event that is recorded must be queryable; silent deletes are an anti-pattern.
1654
- const archiveDir = path.join(stashDir, ".akm", "archive");
1655
- if (fs.existsSync(archiveDir)) {
1656
- const retentionMs = (config.archiveRetentionDays ?? 90) * 86_400_000;
1657
- const cutoff = Date.now() - retentionMs;
1658
- for (const fname of fs.readdirSync(archiveDir)) {
1659
- const fp = path.join(archiveDir, fname);
1660
- try {
1661
- const stat = fs.statSync(fp);
1662
- if (stat.mtimeMs < cutoff) {
1663
- // Emit event before deletion so the record survives the purge.
1664
- appendEvent({
1665
- eventType: "archive_cleanup",
1666
- metadata: {
1667
- file: fname,
1668
- filePath: fp,
1669
- ageMs: Date.now() - stat.mtimeMs,
1670
- retentionMs,
1671
- },
1672
- });
1673
- fs.unlinkSync(fp);
1674
- }
1675
- }
1676
- catch {
1677
- /* ignore race conditions */
1678
- }
1679
- }
1680
- }
2167
+ // [signoff 2026-06-15] TTL archive cleanup machinery RETIRED (WS-3a).
2168
+ // The elaborate archiveRetentionDays / archive-dir scan existed only to satisfy
2169
+ // the old irrecoverability constraint. Stashes are now git-backed, so git
2170
+ // history is the recovery path no bespoke archive TTL needed. Any files in
2171
+ // .akm/archive/ will stay there harmlessly until the operator prunes them with
2172
+ // `git rm` or `find .akm/archive -mtime +90 -delete`. Changed N files this
2173
+ // run; recover any via `git show <sha>:<path>` or `git restore <path>`.
2174
+ if (merged > 0 || deleted > 0 || dedupCollapsed > 0) {
2175
+ const totalChanged = merged + deleted + dedupCollapsed;
2176
+ warnings.push(`Changed ${totalChanged} file(s) this run. Recover any via git if needed (git history is the backstop).`);
2177
+ }
2178
+ const runDurationMs = Date.now() - startMs;
2179
+ const budgetFraction = opts.runBudgetMs !== undefined && opts.runBudgetMs > 0 ? runDurationMs / opts.runBudgetMs : undefined;
1681
2180
  return {
1682
2181
  schemaVersion: 1,
1683
2182
  ok: true,
@@ -1687,7 +2186,10 @@ export async function akmConsolidate(opts = {}) {
1687
2186
  target: sourceName,
1688
2187
  processed: memories.length,
1689
2188
  merged,
1690
- deleted,
2189
+ // #617: fold the deterministic dedup pre-pass collapses into the reported
2190
+ // deleted count. Each collapse removed exactly one variant file with NO
2191
+ // LLM call before the LLM pass ran on the pruned pool.
2192
+ deleted: deleted + dedupCollapsed,
1691
2193
  promoted,
1692
2194
  contradicted,
1693
2195
  failedChunks: totalChunksFailed,
@@ -1697,7 +2199,16 @@ export async function akmConsolidate(opts = {}) {
1697
2199
  mergedSecondaries,
1698
2200
  failedChunkMemories,
1699
2201
  warnings,
1700
- durationMs: Date.now() - startMs,
2202
+ durationMs: runDurationMs,
2203
+ perfTelemetry: {
2204
+ dedupPoolSize: perfMs.dedupPoolSize,
2205
+ llmPoolSize,
2206
+ judgedCacheSkipped: perfMs.judgedCacheSkipped,
2207
+ embedMs: embedTelemetry.embedMs,
2208
+ embedCacheHits: embedTelemetry.cacheHits,
2209
+ embedCacheMisses: embedTelemetry.cacheMisses,
2210
+ ...(budgetFraction !== undefined ? { estimatedBudgetFractionUsed: budgetFraction } : {}),
2211
+ },
1701
2212
  };
1702
2213
  }
1703
2214
  // ── Helpers ─────────────────────────────────────────────────────────────────
@@ -1992,10 +2503,23 @@ async function checkPreEmitDedup(opts) {
1992
2503
  * everything changed or the index can't answer (fail-open to preserve merge
1993
2504
  * correctness). `since` is an ISO timestamp.
1994
2505
  */
1995
- export function narrowToIncrementalCandidates(memories, since, warnings) {
2506
+ /**
2507
+ * Parse a human-readable duration string (e.g. "30m", "24h", "7d") to an ISO
2508
+ * timestamp representing `now - duration`. Returns the input unchanged when it
2509
+ * doesn't match the pattern (assumed to already be an ISO timestamp).
2510
+ */
2511
+ function parseSinceToIso(since) {
2512
+ const m = since.match(/^(\d+)(m|h|d)$/);
2513
+ if (!m)
2514
+ return since;
2515
+ const multiplier = { m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
2516
+ return new Date(Date.now() - parseInt(m[1], 10) * multiplier).toISOString();
2517
+ }
2518
+ export function narrowToIncrementalCandidates(memories, since, warnings, neighborsPerChanged = 5) {
2519
+ const sinceIso = parseSinceToIso(since);
1996
2520
  const isChanged = (m) => {
1997
2521
  try {
1998
- return fs.statSync(m.filePath).mtime.toISOString() > since;
2522
+ return fs.statSync(m.filePath).mtime.toISOString() > sinceIso;
1999
2523
  }
2000
2524
  catch {
2001
2525
  return true; // never silently drop a memory we cannot stat
@@ -2006,7 +2530,6 @@ export function narrowToIncrementalCandidates(memories, since, warnings) {
2006
2530
  return [];
2007
2531
  if (changed.length === memories.length)
2008
2532
  return memories;
2009
- const NEIGHBORS_PER_CHANGED = 5;
2010
2533
  const byName = new Map(memories.map((m) => [m.name, m]));
2011
2534
  const keep = new Set(changed.map((m) => m.name));
2012
2535
  let db;
@@ -2016,7 +2539,7 @@ export function narrowToIncrementalCandidates(memories, since, warnings) {
2016
2539
  const id = findEntryIdByRef(db, `memory:${m.name}`);
2017
2540
  if (id === undefined)
2018
2541
  continue;
2019
- for (const hit of getNeighborsByEntryId(db, id, NEIGHBORS_PER_CHANGED + 1)) {
2542
+ for (const hit of getNeighborsByEntryId(db, id, neighborsPerChanged + 1)) {
2020
2543
  if (hit.id === id)
2021
2544
  continue;
2022
2545
  const entry = getEntryById(db, hit.id);
@@ -2037,7 +2560,7 @@ export function narrowToIncrementalCandidates(memories, since, warnings) {
2037
2560
  closeDatabase(db);
2038
2561
  }
2039
2562
  const candidates = memories.filter((m) => keep.has(m.name));
2040
- warnings.push(`Incremental consolidation: ${changed.length} changed + neighbours → ${candidates.length}/${memories.length} memories considered (since ${since}).`);
2563
+ warnings.push(`Incremental consolidation: ${changed.length} changed + neighbours → ${candidates.length}/${memories.length} memories considered (since ${since}${sinceIso !== since ? ` = ${sinceIso}` : ""}).`);
2041
2564
  return candidates;
2042
2565
  }
2043
2566
  function loadMemoriesForSource(source, stashDir, warnings) {
@@ -2218,8 +2741,8 @@ async function generateMergedContent(config, primaryRef, primaryBody, secondaryR
2218
2741
  }
2219
2742
  normalizeUpdatedField(repairedFmData);
2220
2743
  const repairedYaml = serializeFrontmatter(repairedFmData);
2221
- const bodyPart = mergedFm.content ?? "";
2222
- return { content: `---\n${repairedYaml}\n---\n${bodyPart}` };
2744
+ const bodyPart = typeof mergedFm.content === "string" ? mergedFm.content : "";
2745
+ return { content: assembleAssetFromString(repairedYaml, bodyPart) };
2223
2746
  }
2224
2747
  }
2225
2748
  catch {