akm-cli 0.9.0-beta.44 → 0.9.0-beta.46

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 (80) hide show
  1. package/dist/cli/shared.js +28 -0
  2. package/dist/cli.js +1 -2
  3. package/dist/commands/env/env-cli.js +16 -24
  4. package/dist/commands/env/secret-cli.js +12 -20
  5. package/dist/commands/graph/graph-cli.js +5 -13
  6. package/dist/commands/graph/graph.js +3 -3
  7. package/dist/commands/health.js +5 -0
  8. package/dist/commands/improve/consolidate/chunking.js +141 -0
  9. package/dist/commands/improve/consolidate/eligibility.js +64 -0
  10. package/dist/commands/improve/consolidate/merge.js +145 -0
  11. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  12. package/dist/commands/improve/consolidate/types.js +4 -0
  13. package/dist/commands/improve/consolidate.js +20 -571
  14. package/dist/commands/improve/distill.js +5 -9
  15. package/dist/commands/improve/eligibility.js +434 -0
  16. package/dist/commands/improve/extract-cli.js +9 -1
  17. package/dist/commands/improve/extract.js +5 -19
  18. package/dist/commands/improve/improve-auto-accept.js +4 -8
  19. package/dist/commands/improve/improve-cli.js +35 -60
  20. package/dist/commands/improve/improve-result-file.js +5 -23
  21. package/dist/commands/improve/improve-session.js +58 -0
  22. package/dist/commands/improve/improve.js +107 -3606
  23. package/dist/commands/improve/locks.js +154 -0
  24. package/dist/commands/improve/loop-stages.js +1079 -0
  25. package/dist/commands/improve/preparation.js +1963 -0
  26. package/dist/commands/improve/recombine.js +6 -12
  27. package/dist/commands/improve/reflect.js +29 -34
  28. package/dist/commands/proposal/drain.js +25 -48
  29. package/dist/commands/proposal/proposal-cli.js +21 -31
  30. package/dist/commands/proposal/validators/proposals.js +3 -7
  31. package/dist/commands/read/curate.js +70 -14
  32. package/dist/commands/read/knowledge.js +2 -2
  33. package/dist/commands/sources/self-update.js +2 -2
  34. package/dist/commands/sources/stash-cli.js +9 -37
  35. package/dist/commands/tasks/tasks-cli.js +19 -27
  36. package/dist/commands/wiki-cli.js +21 -35
  37. package/dist/core/config/config.js +18 -2
  38. package/dist/core/events.js +3 -7
  39. package/dist/core/logs-db.js +6 -63
  40. package/dist/core/parse.js +36 -16
  41. package/dist/core/state/migrations.js +714 -0
  42. package/dist/core/state-db.js +28 -779
  43. package/dist/indexer/db/db.js +82 -216
  44. package/dist/indexer/graph/graph-extraction.js +2 -0
  45. package/dist/indexer/indexer.js +11 -112
  46. package/dist/indexer/passes/dir-staleness.js +114 -0
  47. package/dist/indexer/search/search-source.js +10 -24
  48. package/dist/integrations/agent/runner-dispatch.js +59 -0
  49. package/dist/llm/client.js +22 -11
  50. package/dist/llm/graph-extract.js +58 -42
  51. package/dist/llm/memory-infer.js +34 -22
  52. package/dist/llm/metadata-enhance.js +35 -30
  53. package/dist/llm/structured-call.js +49 -0
  54. package/dist/output/shapes/passthrough.js +0 -1
  55. package/dist/registry/providers/skills-sh.js +21 -147
  56. package/dist/registry/providers/static-index.js +15 -157
  57. package/dist/registry/resolve.js +22 -9
  58. package/dist/scripts/migrate-storage.js +892 -1186
  59. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +214 -179
  60. package/dist/setup/setup.js +26 -5
  61. package/dist/sources/providers/filesystem.js +0 -1
  62. package/dist/sources/providers/git-install.js +206 -0
  63. package/dist/sources/providers/git-provider.js +234 -0
  64. package/dist/sources/providers/git-stash.js +248 -0
  65. package/dist/sources/providers/git.js +10 -671
  66. package/dist/sources/providers/npm.js +2 -6
  67. package/dist/sources/providers/sync-from-ref.js +9 -1
  68. package/dist/sources/providers/website.js +2 -3
  69. package/dist/sources/website-ingest.js +51 -9
  70. package/dist/sources/wiki-fetchers/registry.js +53 -0
  71. package/dist/sources/wiki-fetchers/youtube.js +181 -0
  72. package/dist/storage/database.js +45 -10
  73. package/dist/storage/managed-db.js +82 -0
  74. package/dist/storage/repositories/registry-cache.js +92 -0
  75. package/dist/tasks/runner.js +5 -13
  76. package/dist/workflows/runtime/runs.js +1 -117
  77. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  78. package/package.json +5 -5
  79. package/dist/commands/db-cli.js +0 -23
  80. package/dist/indexer/db/db-backup.js +0 -376
@@ -5,7 +5,6 @@ import { createHash } from "node:crypto";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import readline from "node:readline";
8
- import { parse as yamlParse } from "yaml";
9
8
  import consolidateSystemPrompt from "../../assets/prompts/consolidate-system.md" with { type: "text" };
10
9
  import { parseAssetRef } from "../../core/asset/asset-ref.js";
11
10
  import { assembleAssetFromString, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
@@ -17,14 +16,14 @@ import { ConfigError } from "../../core/errors.js";
17
16
  import { parseEmbeddedJsonResponse } from "../../core/parse.js";
18
17
  import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
19
18
  import { detectTruncatedDescription } from "../../core/text-truncation.js";
20
- import { hasHotCaptureMode, hasSupersededStatus, MERGE_ABSOLUTE_FLOOR_CHARS, MERGE_SHRINK_RATIO_MIN, validateProposalFrontmatter, } from "../proposal/validators/proposal-quality-validators.js";
19
+ import { hasSupersededStatus, MERGE_ABSOLUTE_FLOOR_CHARS, MERGE_SHRINK_RATIO_MIN, validateProposalFrontmatter, } from "../proposal/validators/proposal-quality-validators.js";
21
20
  import { createProposal, isProposalSkipped, listProposals } from "../proposal/validators/proposals.js";
22
21
  import { cacheHash, runDeterministicDedup, stripFrontmatterBody } from "./dedup.js";
23
22
  import { checkGenerationGuard, checkLexicalDiversity, computeMergedGeneration, readAssetGeneration, runHomeostaticDemotion, shouldSkipHotProbationInLlm, } from "./homeostatic.js";
24
23
  import { writeContradictEdge } from "./memory/memory-belief.js";
25
24
  // Re-export the moved helpers so existing test imports continue to resolve.
26
25
  export { hasSupersededStatus, validateProposalFrontmatter };
27
- import { getBodyEmbeddings, getConsolidationJudgedMap, openStateDatabase, upsertBodyEmbeddings, upsertConsolidationJudged, } from "../../core/state-db.js";
26
+ import { getBodyEmbeddings, getConsolidationJudgedMap, openStateDatabase, upsertBodyEmbeddings, upsertConsolidationJudged, withStateDb, } from "../../core/state-db.js";
28
27
  import { warn } from "../../core/warn.js";
29
28
  import { commitWriteTargetBoundary, deleteAssetFromSource, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
30
29
  import { closeDatabase, findEntryIdByRef, getAllEntries, getEntryById, getNeighborsByEntryId, openExistingDatabase, } from "../../indexer/db/db.js";
@@ -32,6 +31,22 @@ import { resolveImproveProcessRunnerFromProfile, runnerIsLlm } from "../../integ
32
31
  import { chatCompletion } from "../../llm/client.js";
33
32
  import { cosineSimilarity, embedBatch, resolveEmbeddingModelId } from "../../llm/embedder.js";
34
33
  import { isLlmFeatureEnabled, tryLlmFeature } from "../../llm/feature-gate.js";
34
+ // Chunk sizing + per-chunk prompt assembly live in ./consolidate/chunking.
35
+ // Imported for internal use by the orchestrator and re-exported for importers.
36
+ import { buildChunkPrompt, computeSafeChunkSize, DEFAULT_CONTEXT_LENGTH_TOKENS } from "./consolidate/chunking.js";
37
+ export { buildChunkPrompt, computeSafeChunkSize, DEFAULT_CONTEXT_LENGTH_TOKENS } from "./consolidate/chunking.js";
38
+ // LLM-output sanitization (pure string/frontmatter transforms) lives in
39
+ // ./consolidate/sanitize. Imported for internal use + re-exported for importers.
40
+ import { normalizeUpdatedField, sanitizeMergedContent } from "./consolidate/sanitize.js";
41
+ export { normalizeUpdatedField, sanitizeMergedContent, stripOuterCodeFence } from "./consolidate/sanitize.js";
42
+ // Eligibility / safety predicates live in ./consolidate/eligibility. Imported
43
+ // for internal guard use; the two public predicates are re-exported.
44
+ import { consolidateGuardStatus, isConsolidationEligibleMemoryName, isHotCapturedMemory, } from "./consolidate/eligibility.js";
45
+ export { isConsolidationEligibleMemoryName, isHotCapturedMemory } from "./consolidate/eligibility.js";
46
+ // Plan parsing / merging (pure op-reconciliation algebra) lives in
47
+ // ./consolidate/merge. Imported for internal use; mergePlans re-exported.
48
+ import { isValidOp, mergePlans } from "./consolidate/merge.js";
49
+ export { mergePlans } from "./consolidate/merge.js";
35
50
  // ── Prompts ─────────────────────────────────────────────────────────────────
36
51
  const CONSOLIDATE_SYSTEM_PROMPT = consolidateSystemPrompt;
37
52
  /**
@@ -119,117 +134,6 @@ export const CONSOLIDATE_PLAN_JSON_SCHEMA = {
119
134
  },
120
135
  },
121
136
  };
122
- export function isConsolidationEligibleMemoryName(name) {
123
- return !name.endsWith(".derived");
124
- }
125
- /**
126
- * Returns true when the memory file has `captureMode: hot` in its frontmatter.
127
- *
128
- * Hot memories are USER-EXPLICIT (written via `akm remember` on the hot path).
129
- * The consolidate LLM is forbidden from deleting or auto-merging them — the
130
- * user wrote them on purpose and only the user can decide to retire them.
131
- *
132
- * Reads the file once per check; consolidate runs against ~10 memories per
133
- * chunk so the IO cost is trivial. Returns false on any read/parse error
134
- * (fail-safe: an unparseable file is treated as not-hot, but the broader
135
- * consolidate flow already guards against unparseable memories elsewhere).
136
- *
137
- * Defends against four observed defect classes (see
138
- * `memory:akm-improve-critical-review-2026-05-20`):
139
- * - LLM marks a memory contradicted then deletes (dangling contradictedBy)
140
- * - LLM merges two unrelated memories sharing a topic keyword
141
- * - LLM judges a recent durable design memo as "redundant"
142
- * - Cascade deletes (LLM uses ref:X as `contradictedBy` for ref:Y then deletes both)
143
- */
144
- export function isHotCapturedMemory(filePath) {
145
- try {
146
- if (!fs.existsSync(filePath))
147
- return false;
148
- const content = fs.readFileSync(filePath, "utf8");
149
- const parsed = parseFrontmatter(content);
150
- return hasHotCaptureMode(parsed.data);
151
- }
152
- catch {
153
- return false;
154
- }
155
- }
156
- function consolidateGuardStatus(filePath) {
157
- if (!fs.existsSync(filePath))
158
- return "missing";
159
- let content;
160
- try {
161
- content = fs.readFileSync(filePath, "utf8");
162
- }
163
- catch {
164
- return "unparseable";
165
- }
166
- let parsed;
167
- try {
168
- parsed = parseFrontmatter(content);
169
- }
170
- catch {
171
- return "unparseable";
172
- }
173
- const data = parsed.data;
174
- if (!data || Object.keys(data).length === 0)
175
- return "unparseable";
176
- return hasHotCaptureMode(data) ? "hot" : "safe";
177
- }
178
- // ── Chunk sizing ─────────────────────────────────────────────────────────────
179
- /**
180
- * Conservative chars-per-token estimate used when computing prompt budgets.
181
- * English text averages roughly 4 chars/token for most LLM tokenizers. We use
182
- * 3 to stay conservative (shorter tokens = more tokens per char).
183
- */
184
- const CHARS_PER_TOKEN = 3;
185
- /**
186
- * Overhead budget reserved for the system prompt, chunk header lines, and per-
187
- * memory metadata lines (name, description, tags, separator). Measured at
188
- * roughly 600 chars for the system prompt + ~100 chars of header + ~50 chars
189
- * per memory × chunk size. We round up to 2 000 tokens to leave room for the
190
- * model's own output.
191
- */
192
- const PROMPT_OVERHEAD_TOKENS = 2_000;
193
- /**
194
- * Default effective token budget used when the default LLM profile's
195
- * `contextLength` is not set. This is intentionally conservative (4 096)
196
- * rather than being set to the model's actual context window, because:
197
- *
198
- * - When the agent path is used, the agent CLI (e.g. opencode)
199
- * prepends its own large system prompt + conversation history before
200
- * forwarding to the model. That overhead easily consumes 30K+ tokens on
201
- * a model with a 16K context window, leaving very little room for
202
- * chunk content.
203
- * - When the HTTP path is used (an LLM profile is selected), only the akm
204
- * system prompt and user prompt are sent, so the budget can be set to the
205
- * model's actual context length via profiles.llm[defaults.llm].contextLength.
206
- *
207
- * Set profiles.llm[defaults.llm].contextLength in your config file to the
208
- * model's actual context window to allow larger chunks on the HTTP path.
209
- */
210
- export const DEFAULT_CONTEXT_LENGTH_TOKENS = 4_096;
211
- /**
212
- * Given the model's context window and the per-memory body truncation limit,
213
- * return the maximum number of memories that can safely fit in one chunk
214
- * without the prompt overflowing the context window.
215
- *
216
- * The formula is:
217
- * usableTokens = contextLength - PROMPT_OVERHEAD_TOKENS
218
- * tokensPerMemory = ceil(bodyTruncation / CHARS_PER_TOKEN)
219
- * chunkSize = floor(usableTokens / tokensPerMemory)
220
- *
221
- * Result is clamped between 1 and 50 to avoid degenerate values.
222
- *
223
- * @param contextLength - Model context window in tokens.
224
- * @param bodyTruncation - Max chars per memory body included in the prompt.
225
- * @param maxChunkSize - Optional override for the hardcoded cap of 50 (1–50).
226
- */
227
- export function computeSafeChunkSize(contextLength, bodyTruncation, maxChunkSize) {
228
- const usableTokens = Math.max(contextLength - PROMPT_OVERHEAD_TOKENS, 0);
229
- const tokensPerMemory = Math.max(Math.ceil(bodyTruncation / CHARS_PER_TOKEN), 1);
230
- const raw = Math.floor(usableTokens / tokensPerMemory);
231
- return Math.max(1, Math.min(maxChunkSize ?? 50, raw));
232
- }
233
137
  async function clusterMemoriesBySimilarity(memories, config, stateDb) {
234
138
  const noTelemetry = { embedMs: 0, cacheHits: 0, cacheMisses: 0 };
235
139
  if (memories.length < 3 || !config.embedding)
@@ -362,84 +266,6 @@ async function clusterMemoriesBySimilarity(memories, config, stateDb) {
362
266
  return { ordered, embedTelemetry };
363
267
  }
364
268
  // ── Chunk helpers ────────────────────────────────────────────────────────────
365
- /**
366
- * Build the per-chunk user prompt fed to the consolidate LLM.
367
- *
368
- * Each memory is annotated with two flags that drive the system-prompt
369
- * rules at lines 181-186:
370
- * - `(captureMode: hot)` — user-explicit memory; system prompt rule 2
371
- * forbids proposing delete. ~60 wasted LLM verdicts/4h on this user's
372
- * stack before this annotation.
373
- * - `(already queued)` — the memory's body hash matches a pending
374
- * consolidate proposal; system prompt rule 3 forbids proposing
375
- * promote/merge/contradict. ~107/4h before this annotation.
376
- *
377
- * Both annotations are visible to the LLM. `pendingProposalBodyHashes`
378
- * is precomputed once per run by `loadPendingConsolidateProposalHashes`
379
- * so the cost stays O(memories) inside the chunk loop.
380
- */
381
- export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks, bodyTruncation, pendingProposalBodyHashes = new Set(), standardsContext = "") {
382
- const start = memories[0] ? `memory:${memories[0].name}` : "";
383
- const end = memories[memories.length - 1] ? `memory:${memories[memories.length - 1].name}` : "";
384
- const annotationsByIndex = [];
385
- const hotRefs = [];
386
- for (const m of memories) {
387
- let body = "";
388
- try {
389
- body = fs.readFileSync(m.filePath, "utf8");
390
- }
391
- catch {
392
- body = "(unreadable)";
393
- }
394
- const parsed = parseFrontmatter(body);
395
- const isHot = parsed.data.captureMode === "hot";
396
- // Use cacheHash (case-preserving stripped body) to match the domain used
397
- // by loadPendingConsolidateProposalHashes and the body-embedding cache.
398
- const bodyHash = cacheHash(body);
399
- const isAlreadyQueued = pendingProposalBodyHashes.has(bodyHash);
400
- annotationsByIndex.push({ isHot, isAlreadyQueued, body });
401
- if (isHot)
402
- hotRefs.push(`memory:${m.name}`);
403
- }
404
- const lines = [
405
- `Source: ${sourceName}`,
406
- `Chunk ${chunkIndex + 1} of ${totalChunks}, memories ${start}–${end}:`,
407
- "",
408
- ];
409
- if (standardsContext.trim()) {
410
- lines.push("Standards to follow (the rulebook for this target):");
411
- lines.push(standardsContext.trim());
412
- lines.push("");
413
- }
414
- // Top-of-prompt protection block for hot refs. Neutral phrasing — avoid
415
- // op-words like "promote", "merge", "contradict" so the model doesn't
416
- // accidentally treat the warning as a hint to use that op elsewhere
417
- // (variant B leaked the word "contradict" into the control sample
418
- // during the diagnostic).
419
- if (hotRefs.length > 0) {
420
- lines.push("⛔ DO NOT propose any `delete` operation for these refs — they are user-explicit (captureMode: hot) and the downstream guard refuses them regardless. Proposing delete for any of these only wastes tokens.");
421
- for (const ref of hotRefs)
422
- lines.push(` - ${ref}`);
423
- lines.push("");
424
- }
425
- for (let i = 0; i < memories.length; i++) {
426
- const m = memories[i];
427
- const { isHot, isAlreadyQueued, body } = annotationsByIndex[i];
428
- const annotations = [];
429
- if (isHot)
430
- annotations.push("captureMode: hot");
431
- if (isAlreadyQueued)
432
- annotations.push("already queued");
433
- const annotationSuffix = annotations.length > 0 ? ` (${annotations.join("; ")})` : "";
434
- lines.push(`[${i + 1}] memory:${m.name}${annotationSuffix}`);
435
- lines.push(`Description: ${m.description || "(none)"}`);
436
- lines.push(`Tags: ${m.tags.length > 0 ? m.tags.join(", ") : "(none)"}`);
437
- lines.push("---");
438
- lines.push(body.slice(0, bodyTruncation));
439
- lines.push("");
440
- }
441
- return lines.join("\n");
442
- }
443
269
  /**
444
270
  * Precompute body-hashes of all currently-pending consolidate proposals so
445
271
  * the per-chunk prompt can annotate memories whose body would just produce
@@ -466,148 +292,6 @@ function loadPendingConsolidateProposalHashes(stashDir) {
466
292
  }
467
293
  return hashes;
468
294
  }
469
- function isValidOp(op) {
470
- if (typeof op !== "object" || op === null)
471
- return false;
472
- const o = op;
473
- if (o.op === "merge") {
474
- return typeof o.primary === "string" && Array.isArray(o.secondaries);
475
- }
476
- if (o.op === "delete") {
477
- return typeof o.ref === "string";
478
- }
479
- if (o.op === "promote") {
480
- return typeof o.ref === "string" && typeof o.knowledgeRef === "string";
481
- }
482
- if (o.op === "contradict") {
483
- return typeof o.ref === "string" && typeof o.contradictedByRef === "string";
484
- }
485
- return false;
486
- }
487
- export function mergePlans(chunks, knownRefs) {
488
- const mergeOps = new Map();
489
- const deleteOps = new Map();
490
- const promoteOps = new Map();
491
- // C-3 / #382: contradict ops keyed by `ref|contradictedByRef` to deduplicate.
492
- const contradictOps = new Map();
493
- const warnings = [];
494
- for (const chunk of chunks) {
495
- for (const op of chunk) {
496
- if (op.op === "merge") {
497
- // Drop ops whose primary the LLM hallucinated (not in the loaded memory
498
- // pool). Without this guard, a hallucinated primary flows all the way to
499
- // Phase B where !memoryByRef.has(primary) fires and charges every real
500
- // secondary with merge_primary_missing — masking LLM hallucinations as
501
- // filter regressions in health metrics.
502
- if (knownRefs && !knownRefs.has(op.primary)) {
503
- warnings.push(`mergePlans: primary ${op.primary} not in loaded memory pool (LLM hallucination) — dropping op before execution.`);
504
- // Use a dedicated skip reason so dashboards can distinguish
505
- // hallucinated primaries from stale-DB regressions.
506
- // Secondaries are real refs; they are NOT charged here — they remain
507
- // available for other ops to claim.
508
- continue;
509
- }
510
- // Filter hallucinated secondaries while preserving real ones.
511
- let mergeOp = op;
512
- if (knownRefs) {
513
- const filteredSecondaries = op.secondaries.filter((sec) => {
514
- if (!knownRefs.has(sec)) {
515
- warnings.push(`mergePlans: secondary ${sec} not in loaded memory pool (LLM hallucination) — dropping from op.`);
516
- return false;
517
- }
518
- return true;
519
- });
520
- if (filteredSecondaries.length !== op.secondaries.length) {
521
- mergeOp = { ...op, secondaries: filteredSecondaries };
522
- }
523
- }
524
- // merge wins over delete
525
- if (deleteOps.has(mergeOp.primary)) {
526
- deleteOps.delete(mergeOp.primary);
527
- }
528
- for (const sec of mergeOp.secondaries) {
529
- if (deleteOps.has(sec))
530
- deleteOps.delete(sec);
531
- }
532
- mergeOps.set(mergeOp.primary, mergeOp);
533
- }
534
- else if (op.op === "delete") {
535
- // merge and promote both win over delete. A promote is non-destructive
536
- // (creates a proposal) but the source memory is counted in `promoted`;
537
- // if a delete also fires, the ref lands in both `promoted` and
538
- // `skipReasons`, breaking the invariant by +1.
539
- if (!mergeOps.has(op.ref) && !promoteOps.has(op.ref)) {
540
- deleteOps.set(op.ref, op);
541
- }
542
- }
543
- else if (op.op === "promote") {
544
- // C-2 / #381: when both a promote and a merge target the same ref,
545
- // queue the promote FIRST rather than discarding it. The promote op
546
- // routes through createProposal (the human-gated proposal queue), so
547
- // it is non-destructive. The merge follows after the proposal is
548
- // created. This preserves the human reviewer's ability to inspect the
549
- // promotion before the source memory is merged/deleted.
550
- // AGM K*8 — retain the maximally informative consistent subset.
551
- promoteOps.set(op.ref, op);
552
- }
553
- else if (op.op === "contradict") {
554
- // Deduplicate by ref+contradictedByRef pair.
555
- const key = `${op.ref}|${op.contradictedByRef}`;
556
- if (!contradictOps.has(key)) {
557
- contradictOps.set(key, op);
558
- }
559
- }
560
- }
561
- }
562
- // Second pass: enforce merge-wins-over-delete and deduplicate secondaries.
563
- //
564
- // 1. Delete/secondary ordering bug: the per-chunk loop removes delete ops
565
- // for secondaries that were already in deleteOps, but misses the case
566
- // where the delete chunk came first. A full sweep here fixes both orders.
567
- //
568
- // 2. Cross-merge secondary dedup: if ref A is a secondary in two merge ops,
569
- // only the first (insertion-order) retains it. Without this, a successful
570
- // merge credits A to mergedSecondaries and a later merge's emitMerge-
571
- // FailureSkips also charges A to skipReasons — double-counting A while
572
- // processed has it only once.
573
- //
574
- // 3. Primary-as-secondary dedup: if ref A is a primary in one merge op and
575
- // a secondary in another, remove A from the secondary list. Both merges
576
- // would otherwise claim A (merged++ for A, then mergedSecondaries++ for A)
577
- // breaking the invariant the same way.
578
- // Also remove delete ops for any ref claimed by a promote op (handles the
579
- // case where the delete chunk appeared before the promote chunk).
580
- for (const ref of promoteOps.keys()) {
581
- deleteOps.delete(ref);
582
- }
583
- const claimedSecondaries = new Set();
584
- for (const mergeOp of mergeOps.values()) {
585
- deleteOps.delete(mergeOp.primary);
586
- mergeOp.secondaries = mergeOp.secondaries.filter((sec) => {
587
- if (mergeOps.has(sec)) {
588
- warnings.push(`Merge: secondary ${sec} is also a merge primary — removing from secondary list to avoid double-count.`);
589
- return false;
590
- }
591
- if (claimedSecondaries.has(sec)) {
592
- warnings.push(`Merge: secondary ${sec} appears in multiple merge ops — retaining in first op only.`);
593
- return false;
594
- }
595
- claimedSecondaries.add(sec);
596
- deleteOps.delete(sec);
597
- return true;
598
- });
599
- }
600
- // C-2 / #381: promote ops are ordered BEFORE merge ops so that the
601
- // human-gated proposal queue entry is created before any destructive merge.
602
- // Phase B processes ops in array order, so promote executes first.
603
- const ops = [
604
- ...promoteOps.values(),
605
- ...mergeOps.values(),
606
- ...deleteOps.values(),
607
- ...contradictOps.values(),
608
- ];
609
- return { ops, warnings };
610
- }
611
295
  function getJournalPath(stashDir) {
612
296
  return path.join(stashDir, ".akm", "consolidate-journal.json");
613
297
  }
@@ -1056,18 +740,13 @@ async function akmConsolidateInner(opts, config, stashDir, startMs, sourceRun, w
1056
740
  }
1057
741
  }
1058
742
  else {
1059
- let localDb;
1060
743
  try {
1061
- localDb = openStateDatabase();
1062
- cachedMap = getConsolidationJudgedMap(localDb, memories.map((m) => `memory:${m.name}`));
744
+ cachedMap = withStateDb((localDb) => getConsolidationJudgedMap(localDb, memories.map((m) => `memory:${m.name}`)));
1063
745
  }
1064
746
  catch {
1065
747
  // State DB unavailable → fail open: judge the full pool this run.
1066
748
  cachedMap = new Map();
1067
749
  }
1068
- finally {
1069
- localDb?.close();
1070
- }
1071
750
  }
1072
751
  }
1073
752
  const beforeCount = memories.length;
@@ -1525,17 +1204,12 @@ async function akmConsolidateInner(opts, config, stashDir, startMs, sourceRun, w
1525
1204
  }
1526
1205
  }
1527
1206
  else {
1528
- let localDb;
1529
1207
  try {
1530
- localDb = openStateDatabase();
1531
- doRecord(localDb);
1208
+ withStateDb((localDb) => doRecord(localDb));
1532
1209
  }
1533
1210
  catch (e) {
1534
1211
  warnings.push(`Judged-state cache: failed to record judged state: ${String(e)}`);
1535
1212
  }
1536
- finally {
1537
- localDb?.close();
1538
- }
1539
1213
  }
1540
1214
  }
1541
1215
  // Build the known-refs set from the already-filtered memory pool so
@@ -2222,231 +1896,6 @@ async function akmConsolidateInner(opts, config, stashDir, startMs, sourceRun, w
2222
1896
  };
2223
1897
  }
2224
1898
  // ── Helpers ─────────────────────────────────────────────────────────────────
2225
- // ── LLM-output sanitization ─────────────────────────────────────────────────
2226
- //
2227
- // Three classes of LLM defect have been observed across hundreds of
2228
- // consolidate proposals (see audit notes in this branch):
2229
- //
2230
- // 1. Code-fence leakage: the entire merged asset is wrapped in
2231
- // ```markdown … ``` (or ```yaml … ```) despite the prompt forbidding
2232
- // fences. The post-processor used to pass this through verbatim, so the
2233
- // first character of the asset content became a backtick rather than
2234
- // `---`, defeating the frontmatter parser.
2235
- // 2. YAML quote-escaping bugs: descriptions like `'"Specialty intro...:`
2236
- // with unbalanced quotes that break the YAML reader. The post-processor
2237
- // historically passed the LLM's raw scalar straight into a manually
2238
- // assembled `description: <raw>` line.
2239
- // 3. Truncated descriptions hitting token cutoffs — the model's max_tokens
2240
- // runs out mid-sentence, leaving things like
2241
- // `description: "Tables in narrow column containers need max-width:100% +"`
2242
- // with no closing context.
2243
- //
2244
- // `sanitizeMergedContent` and `validateProposalFrontmatter` defend against
2245
- // all three at the point where LLM output is consumed.
2246
- /**
2247
- * Attempt to recover a frontmatter block that is missing its closing `---`.
2248
- *
2249
- * Scans lines after the opening `---` for the first blank line or the first
2250
- * line that cannot be a YAML scalar (i.e. not a key-value, indented
2251
- * continuation, comment, or list item). Injects `---` before that line so
2252
- * the normal parser can proceed.
2253
- *
2254
- * Returns the patched string on success, or `null` if the structure is too
2255
- * ambiguous to recover safely (e.g. no opening `---`, or no body content
2256
- * found after the frontmatter key-value lines).
2257
- */
2258
- function recoverMalformedFrontmatter(raw) {
2259
- if (!raw.startsWith("---"))
2260
- return null;
2261
- const lines = raw.split(/\r?\n/);
2262
- // Skip the opening `---` line (index 0).
2263
- let insertAt = -1;
2264
- for (let i = 1; i < lines.length; i++) {
2265
- const line = lines[i];
2266
- // A blank line marks the end of the frontmatter block in many YAML variants.
2267
- if (line.trim() === "") {
2268
- insertAt = i;
2269
- break;
2270
- }
2271
- // A line that is clearly body content: doesn't look like a YAML key, an
2272
- // indented continuation, a comment, or a sequence item.
2273
- const isYaml = /^\w[\w-]*\s*:/.test(line) || // key: value
2274
- /^\s+\S/.test(line) || // indented continuation / nested
2275
- /^\s*#/.test(line) || // YAML comment
2276
- /^\s*-\s/.test(line); // sequence item
2277
- if (!isYaml) {
2278
- insertAt = i;
2279
- break;
2280
- }
2281
- }
2282
- if (insertAt < 0)
2283
- return null;
2284
- const result = [...lines.slice(0, insertAt), "---", ...lines.slice(insertAt)].join("\n");
2285
- return result;
2286
- }
2287
- /**
2288
- * Outer-fence stripper specific to consolidate. Unlike the shared
2289
- * `stripMarkdownFences` helper (which only handles markdown fences), this
2290
- * variant additionally recognises `yaml` and bare-language fences and refuses
2291
- * to strip an unbalanced fence — i.e. a leading ``` with no trailing ``` is
2292
- * treated as a malformed response, not partially sanitized.
2293
- *
2294
- * Returns `null` when only one half of a fence pair is present (caller
2295
- * should reject the response entirely).
2296
- */
2297
- export function stripOuterCodeFence(raw) {
2298
- const trimmed = raw.trim();
2299
- const leading = trimmed.match(/^```(?:markdown|md|yaml|yml)?\s*\r?\n/i);
2300
- const trailing = trimmed.match(/\r?\n```\s*$/);
2301
- if (!leading && !trailing)
2302
- return { content: trimmed, stripped: false };
2303
- if (!leading || !trailing)
2304
- return null; // unbalanced — refuse
2305
- const inner = trimmed.slice(leading[0].length, trimmed.length - trailing[0].length).trim();
2306
- return { content: inner, stripped: true };
2307
- }
2308
- export function sanitizeMergedContent(raw) {
2309
- // Step 1: Strip outer code fence.
2310
- // Recovery path: if only the leading fence is present, strip it and continue
2311
- // provided the inner content starts with `---`. Trailing-only fences are NOT
2312
- // recovered — a trailing ``` is more likely a body code block than a forgotten
2313
- // wrapper, so recovering would silently corrupt the body.
2314
- let body;
2315
- {
2316
- const fenceResult = stripOuterCodeFence(raw);
2317
- if (fenceResult) {
2318
- body = fenceResult.content;
2319
- }
2320
- else {
2321
- const trimmed = raw.trim();
2322
- const leadingMatch = trimmed.match(/^```(?:markdown|md|yaml|yml)?\s*\r?\n([\s\S]*)$/i);
2323
- const inner = leadingMatch ? leadingMatch[1].trim() : null;
2324
- if (!inner?.startsWith("---")) {
2325
- return { ok: false, reason: "UNBALANCED_CODE_FENCE" };
2326
- }
2327
- body = inner;
2328
- }
2329
- }
2330
- // Strip <think> blocks (some local models still emit them despite system prompts).
2331
- body = body.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
2332
- // Step 2: Verify frontmatter sentinel.
2333
- // Recovery path: LLM sometimes emits 1-2 lines of preamble (e.g. "Here is the
2334
- // merged content:") before the `---`. Accept if `---` appears within 300 chars.
2335
- // Beyond that it's more likely a body section divider, not a frontmatter start.
2336
- if (!body.startsWith("---")) {
2337
- const nlIdx = body.indexOf("\n---");
2338
- if (nlIdx >= 0 && nlIdx < 300) {
2339
- body = body.slice(nlIdx + 1);
2340
- }
2341
- else {
2342
- return { ok: false, reason: "MISSING_FRONTMATTER_SENTINEL" };
2343
- }
2344
- }
2345
- // Extract frontmatter block.
2346
- // Recovery path: LLM sometimes omits the closing `---` delimiter. Detect this
2347
- // by scanning lines after the opening `---` for the first blank line or the
2348
- // first line that isn't a YAML key-value pair, then inject `---` there.
2349
- let match = body.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r\n|\r|\n|$)([\s\S]*)$/);
2350
- if (!match) {
2351
- const recovered = recoverMalformedFrontmatter(body);
2352
- if (recovered) {
2353
- match = recovered.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r\n|\r|\n|$)([\s\S]*)$/);
2354
- }
2355
- if (!match) {
2356
- return { ok: false, reason: "MALFORMED_FRONTMATTER_BLOCK" };
2357
- }
2358
- }
2359
- // Re-parse via the yaml library so any quote-escaping mistakes either get
2360
- // normalised or surface as a parse error we can reject.
2361
- // Recovery: if the strict yaml library fails, fall back to the lenient
2362
- // hand-rolled parseFrontmatter parser, which tolerates common LLM YAML
2363
- // quirks (unescaped special chars, bare scalars, etc.). If it recovers
2364
- // at least one key, proceed — serializeFrontmatter below will re-serialize
2365
- // cleanly. Only reject if both parsers fail to extract any data.
2366
- let parsedFm;
2367
- try {
2368
- parsedFm = yamlParse(match[1]);
2369
- }
2370
- catch (e) {
2371
- const fallback = parseFrontmatter(`---\n${match[1]}\n---\n${match[2]}`);
2372
- if (fallback.frontmatter !== null && Object.keys(fallback.data).length > 0) {
2373
- parsedFm = fallback.data;
2374
- }
2375
- else {
2376
- return { ok: false, reason: `INVALID_YAML: ${e instanceof Error ? e.message : String(e)}` };
2377
- }
2378
- }
2379
- if (parsedFm === null || typeof parsedFm !== "object" || Array.isArray(parsedFm)) {
2380
- return { ok: false, reason: "FRONTMATTER_NOT_OBJECT" };
2381
- }
2382
- const fm = parsedFm;
2383
- // Normalise placeholder leaks like `updated: today`, `updated: {today: null}`,
2384
- // `updated: now`, etc. The consolidate prompt instructs the LLM not to emit
2385
- // these, but small models still do. Replace any such leak with today's ISO
2386
- // date OR drop the field if we can't safely normalise it.
2387
- normalizeUpdatedField(fm);
2388
- // Re-serialise via yaml.stringify to fix any quoting quirks.
2389
- let serialized;
2390
- try {
2391
- serialized = serializeFrontmatter(fm);
2392
- }
2393
- catch (e) {
2394
- return { ok: false, reason: `YAML_STRINGIFY_FAILED: ${e instanceof Error ? e.message : String(e)}` };
2395
- }
2396
- const cleaned = assembleAssetFromString(serialized, match[2]);
2397
- return { ok: true, result: { content: cleaned, frontmatter: fm } };
2398
- }
2399
- /**
2400
- * Mutate `fm.updated` in place to normalise placeholder leaks emitted by the
2401
- * LLM. The consolidate prompt forbids these, but small models still produce
2402
- * literal `today` / `{today: null}` / `now` values.
2403
- *
2404
- * Rules:
2405
- * - A real ISO-style date string (YYYY-MM-DD, optionally with time) stays as-is.
2406
- * - A Date object (some YAML parsers materialise dates) is converted to its
2407
- * ISO yyyy-mm-dd form.
2408
- * - A placeholder string ("today", "now", "{today}", "${today}", template
2409
- * variables) is replaced with today's ISO date.
2410
- * - A map/object (e.g. `{today: null}`) is replaced with today's ISO date.
2411
- * - `null`, empty string, missing → left alone (no field added; reviewers
2412
- * should not silently gain metadata they didn't write).
2413
- *
2414
- * Exported for unit testing.
2415
- */
2416
- export function normalizeUpdatedField(fm) {
2417
- if (!("updated" in fm))
2418
- return;
2419
- const v = fm.updated;
2420
- if (v === null || v === undefined || v === "")
2421
- return;
2422
- const todayIso = new Date().toISOString().slice(0, 10);
2423
- if (v instanceof Date) {
2424
- fm.updated = v.toISOString().slice(0, 10);
2425
- return;
2426
- }
2427
- if (typeof v === "string") {
2428
- const trimmed = v.trim().toLowerCase();
2429
- if (/^\d{4}-\d{2}-\d{2}/.test(v.trim()))
2430
- return; // already a real date
2431
- if (trimmed === "today" ||
2432
- trimmed === "now" ||
2433
- trimmed === "{today}" ||
2434
- // biome-ignore lint/suspicious/noTemplateCurlyInString: matches the literal user-typed placeholder text "${today}" so we can normalize it to today's ISO date
2435
- trimmed === "${today}" ||
2436
- trimmed === "{{today}}" ||
2437
- /^\{?\s*today\s*\}?$/.test(trimmed)) {
2438
- fm.updated = todayIso;
2439
- return;
2440
- }
2441
- // Unknown string format — leave alone so it's visible in the diff.
2442
- return;
2443
- }
2444
- if (typeof v === "object") {
2445
- // Maps like `{today: null}`, `{now: null}` — clearly a template leak.
2446
- fm.updated = todayIso;
2447
- return;
2448
- }
2449
- }
2450
1899
  /**
2451
1900
  * Normalise a knowledge slug for variant-aware deduplication. Collapses:
2452
1901
  * - date suffixes (`-may-2026`, `-2026-05-03`, `-2026`)