akm-cli 0.9.17-alpha.8 → 0.9.17-alpha.9

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 (104) hide show
  1. package/CHANGELOG.md +334 -0
  2. package/STABILITY.md +9 -8
  3. package/dist/assets/hints/cli-hints-full.md +6 -7
  4. package/dist/assets/improve-strategies/catchup.json +0 -3
  5. package/dist/assets/improve-strategies/consolidate.json +0 -1
  6. package/dist/assets/improve-strategies/default.json +1 -2
  7. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -2
  8. package/dist/assets/improve-strategies/quick.json +1 -2
  9. package/dist/assets/improve-strategies/reflect-distill.json +1 -2
  10. package/dist/assets/improve-strategies/thorough.json +0 -3
  11. package/dist/assets/prompts/consolidate-pair.md +20 -0
  12. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +17 -19
  13. package/dist/assets/stash-skeleton/facts/conventions/domains.md +2 -2
  14. package/dist/assets/templates/html/health.html +3 -5
  15. package/dist/cli/retired-commands.js +1 -1
  16. package/dist/commands/health/archive-usage.js +98 -0
  17. package/dist/commands/health/data-dir-usage.js +25 -13
  18. package/dist/commands/health/html-report.js +1 -4
  19. package/dist/commands/health/improve-metrics.js +0 -25
  20. package/dist/commands/health/md-report.js +1 -6
  21. package/dist/commands/health/report-view-model.js +4 -14
  22. package/dist/commands/health/windows.js +0 -1
  23. package/dist/commands/health.js +13 -0
  24. package/dist/commands/improve/consolidate/continuity-check.js +137 -0
  25. package/dist/commands/improve/consolidate/pair-pass.js +791 -0
  26. package/dist/commands/improve/consolidate.js +38 -63
  27. package/dist/commands/improve/extract-prompt.js +1 -2
  28. package/dist/commands/improve/improve-cli.js +1 -1
  29. package/dist/commands/improve/improve-strategies.js +23 -5
  30. package/dist/commands/improve/improve.js +19 -30
  31. package/dist/commands/improve/ledger.js +3 -2
  32. package/dist/commands/improve/loop-stages.js +5 -85
  33. package/dist/commands/improve/memory/memory-belief.js +3 -1
  34. package/dist/commands/improve/memory/memory-improve.js +269 -11
  35. package/dist/commands/improve/planner.js +0 -5
  36. package/dist/commands/improve/preparation.js +20 -135
  37. package/dist/commands/improve/retrieval-scope.js +19 -4
  38. package/dist/commands/improve/salience.js +1 -14
  39. package/dist/commands/improve/stage.js +0 -1
  40. package/dist/commands/lint/base-linter.js +19 -11
  41. package/dist/commands/proposal/drain.js +8 -1
  42. package/dist/commands/proposal/proposal-cli.js +16 -2
  43. package/dist/commands/proposal/proposal-types.js +7 -0
  44. package/dist/commands/proposal/proposal.js +37 -6
  45. package/dist/commands/proposal/repository.js +613 -4
  46. package/dist/commands/proposal/validators/proposals.js +9 -0
  47. package/dist/commands/read/knowledge.js +3 -2
  48. package/dist/commands/read/show.js +0 -14
  49. package/dist/commands/sources/stash-cli.js +2 -2
  50. package/dist/core/bundle-rename.js +1 -7
  51. package/dist/core/config/config-schema.js +8 -1
  52. package/dist/core/config/config.js +23 -48
  53. package/dist/core/config/engine-semantics.js +0 -2
  54. package/dist/core/config/schema/improve-processes.js +17 -42
  55. package/dist/core/config/schema/index-config.js +5 -25
  56. package/dist/core/file-change.js +13 -5
  57. package/dist/core/improve-result.js +16 -5
  58. package/dist/core/improve-types.js +0 -1
  59. package/dist/core/loopback.js +7 -12
  60. package/dist/core/parse.js +13 -16
  61. package/dist/core/state/migrations.js +15 -0
  62. package/dist/core/time.js +0 -20
  63. package/dist/indexer/db/llm-cache.js +2 -2
  64. package/dist/indexer/ensure-index.js +2 -2
  65. package/dist/indexer/index-written-assets.js +2 -3
  66. package/dist/indexer/indexer.js +18 -418
  67. package/dist/indexer/passes/metadata.js +0 -19
  68. package/dist/indexer/walk/walker.js +3 -4
  69. package/dist/llm/client.js +8 -10
  70. package/dist/llm/embedders/remote.js +1 -2
  71. package/dist/llm/feature-gate.js +0 -5
  72. package/dist/output/shapes/helpers.js +20 -4
  73. package/dist/output/text/command-format.js +0 -8
  74. package/dist/output/text/proposal-format.js +47 -1
  75. package/dist/output/text/show-format.js +0 -20
  76. package/dist/scripts/akm-migrate-node.js +917 -950
  77. package/dist/scripts/akm-migrate.js +917 -950
  78. package/dist/setup/steps/connection.js +5 -6
  79. package/dist/setup/steps/platforms.js +2 -2
  80. package/dist/sources/providers/git-stash.js +55 -4
  81. package/dist/storage/repositories/improve-ledger-repository.js +48 -7
  82. package/dist/storage/repositories/index-entries-repository.js +4 -7
  83. package/dist/storage/repositories/index-entry-schema.js +4 -2
  84. package/dist/storage/repositories/index-llm-cache-repository.js +7 -26
  85. package/dist/storage/repositories/index-schema.js +55 -104
  86. package/dist/storage/repositories/proposals-repository.js +61 -0
  87. package/dist/storage/repositories/salience-repository.js +1 -19
  88. package/docs/reference/cli.md +16 -19
  89. package/docs/reference/configuration.md +21 -12
  90. package/docs/reference/data-and-telemetry.md +0 -1
  91. package/package.json +1 -1
  92. package/schemas/akm-config.json +0 -342
  93. package/dist/assets/improve-strategies/graph-refresh.json +0 -15
  94. package/dist/assets/prompts/contradiction-judge.md +0 -33
  95. package/dist/assets/prompts/graph-extract-system.md +0 -1
  96. package/dist/assets/prompts/graph-extract-user-prompt.md +0 -35
  97. package/dist/assets/prompts/metadata-enhance-system.md +0 -1
  98. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +0 -4
  99. package/dist/indexer/db/graph-db.js +0 -399
  100. package/dist/indexer/graph/graph-extraction.js +0 -809
  101. package/dist/indexer/graph/graph-related.js +0 -131
  102. package/dist/indexer/graph/graph-types.js +0 -4
  103. package/dist/llm/graph-extract.js +0 -892
  104. package/dist/llm/metadata-enhance.js +0 -95
@@ -7,11 +7,12 @@ import { assembleAsset } from "../../../core/asset/asset-serialize.js";
7
7
  import { mutateFrontmatter, parseFrontmatter } from "../../../core/asset/frontmatter.js";
8
8
  import { MEMORY_ARCHIVE_REL } from "../../../core/asset/memory-archive.js";
9
9
  import { conceptIdFromTypeName } from "../../../core/asset/resolve-ref.js";
10
- import { asNonEmptyString, groupBy, stringArray } from "../../../core/common.js";
10
+ import { asNonEmptyString, groupBy, stringArray, toPosix } from "../../../core/common.js";
11
11
  import { DERIVED_SUFFIX } from "../../../core/recognition-util.js";
12
12
  import { warn } from "../../../core/warn.js";
13
13
  import { recordWrittenPath } from "../../../core/write-provenance.js";
14
14
  import { walkMarkdownFiles } from "../../../indexer/walk/walker.js";
15
+ import { isGitBackedStash, tryListGitChangedPaths, tryListGitTrackedPaths, tryListGitUnverifiablePaths, } from "../../../sources/providers/git-stash.js";
15
16
  import { contentHash } from "../content-hash.js";
16
17
  import { isDerivedMemory, memoryIdentityRef, parseMemoryName, resolveParentRef } from "./derived-ref.js";
17
18
  export function analyzeMemoryCleanup(stashDir, options = {}) {
@@ -463,17 +464,80 @@ function stronglyConnectedComponents(refs, edges) {
463
464
  }
464
465
  return { components, componentIndexByRef };
465
466
  }
466
- function archiveCleanupCandidate(stashDir, candidate, filePath) {
467
+ /**
468
+ * The `.derived` twin of a non-derived memory file, if one exists on disk:
469
+ * `<name>.derived.md` beside it, the naming convention
470
+ * `indexer/passes/memory-inference.ts`'s `derivedChildPath` writes.
471
+ * `undefined` for a knowledge or lesson ref (no such twin exists), or for a
472
+ * memory that is already itself `.derived` (it has no further twin).
473
+ *
474
+ * Used by `akm proposal accept` (alpha.9) to take a retired or promoted
475
+ * memory's derived child along when it archives the memory.
476
+ */
477
+ export function derivedTwinPath(filePath, refType) {
478
+ if (refType !== "memory" || filePath.endsWith(`${DERIVED_SUFFIX}.md`))
479
+ return undefined;
480
+ const twin = `${filePath.slice(0, -3)}${DERIVED_SUFFIX}.md`;
481
+ return fs.existsSync(twin) ? twin : undefined;
482
+ }
483
+ /**
484
+ * True for a retire-proposal-caused archive (alpha.9: the consolidate pair
485
+ * pass, or O1's promotion retirement) — distinguished from a memory-cleanup
486
+ * family-prune candidate by carrying a `proposalId`. The two paths differ in
487
+ * how `previousBeliefState` is derived (below) and in which extra tombstone
488
+ * fields apply.
489
+ */
490
+ function isRetireCandidate(candidate) {
491
+ return candidate.proposalId !== undefined;
492
+ }
493
+ /**
494
+ * The tombstone's `previousBeliefState`. Memory cleanup's own family-prune
495
+ * candidates keep their original reason-based inference (unchanged, so
496
+ * existing behavior is not disturbed by this generalization). A
497
+ * retire-proposal candidate has no such reason vocabulary to infer from, so
498
+ * it reads the asset's ACTUAL frontmatter `beliefState` instead — more
499
+ * correct, and available because every retire path already has the file on
500
+ * disk right before the move.
501
+ */
502
+ function resolvePreviousBeliefState(candidate, filePath) {
503
+ if (!isRetireCandidate(candidate))
504
+ return priorBeliefStateForArchive(candidate);
505
+ try {
506
+ return resolveBeliefState(parseFrontmatter(fs.readFileSync(filePath, "utf8")).data);
507
+ }
508
+ catch {
509
+ return "active";
510
+ }
511
+ }
512
+ /**
513
+ * Move `filePath` into the recoverable cleanup archive
514
+ * (`.akm/memory-cleanup/archive/<stamp>-<ref>/`) with a `cleanup.md`
515
+ * tombstone, journaling both ends (`recordWrittenPath`) so a LATER sync
516
+ * commits the move — `akm sync`, or the batched auto-sync an `akm improve`
517
+ * run does at its own end (`docs/architecture/improvement.md`, "Auto-sync").
518
+ * This call does not itself commit anything: a standalone `akm proposal
519
+ * accept` (the only way a retire proposal is ever accepted — triage never
520
+ * auto-accepts one) leaves the move journaled but uncommitted until
521
+ * something later reads that journal, unless the write target's `kind` is
522
+ * `"git"`, in which case the caller's own `commitWriteTargetBoundary` commits
523
+ * (and maybe pushes) immediately as part of the SAME accept.
524
+ *
525
+ * Generalized in alpha.9 to cover any memory, knowledge or lesson file in a
526
+ * writable bundle — not only `.derived` memories — so `akm proposal accept`
527
+ * can archive a consolidate pair-pass `retire` proposal's target, or (O1) an
528
+ * accepted promotion's source memory, through the same one encoding memory
529
+ * cleanup already used (D27: never two coexisting encodings). A
530
+ * retire-proposal candidate (one carrying `proposalId`) additionally stamps
531
+ * `proposalId`, `successorRefs` and `retiredAt` on the tombstone.
532
+ */
533
+ export function archiveCleanupCandidate(stashDir, candidate, filePath) {
467
534
  const archivedAt = new Date().toISOString();
535
+ const previousBeliefState = resolvePreviousBeliefState(candidate, filePath);
468
536
  const originalPath = path.relative(stashDir, filePath).replace(/\\/g, "/");
469
537
  const archiveDir = createArchiveDir(stashDir, candidate.ref, archivedAt);
470
538
  const archivedPath = path.join(archiveDir, originalPath);
471
539
  fs.mkdirSync(path.dirname(archivedPath), { recursive: true });
472
- fs.renameSync(filePath, archivedPath);
473
- // #652: an archive is a delete + a create. BOTH ends are journaled so the
474
- // sync stages the removal of the original alongside the archived copy.
475
- recordWrittenPath(filePath);
476
- recordWrittenPath(archivedPath);
540
+ const retiring = isRetireCandidate(candidate);
477
541
  const archiveRef = path.relative(stashDir, archivedPath).replace(/\\/g, "/");
478
542
  const auditPath = path.join(archiveDir, "cleanup.md");
479
543
  const auditRef = path.relative(stashDir, auditPath).replace(/\\/g, "/");
@@ -482,29 +546,223 @@ function archiveCleanupCandidate(stashDir, candidate, filePath) {
482
546
  kind: "memory-cleanup-archive",
483
547
  archivedAt,
484
548
  beliefState: "archived",
485
- previousBeliefState: priorBeliefStateForArchive(candidate),
549
+ previousBeliefState,
486
550
  ref: candidate.ref,
487
- parentRef: candidate.parentRef,
551
+ ...(candidate.parentRef ? { parentRef: candidate.parentRef } : {}),
488
552
  reason: candidate.reason,
489
553
  ...(candidate.survivorRef ? { survivorRef: candidate.survivorRef } : {}),
490
554
  originalPath,
491
555
  archivedPath: archiveRef,
556
+ ...(retiring ? { proposalId: candidate.proposalId, retiredAt: archivedAt } : {}),
557
+ ...(retiring && candidate.successorRefs && candidate.successorRefs.length > 0
558
+ ? { successorRefs: candidate.successorRefs }
559
+ : {}),
492
560
  }, "Archived derived memory for recoverable cleanup.\n");
561
+ // 4c (third review round): write the tombstone BEFORE moving the file — a
562
+ // crash in between used to leave a file already at archivedPath with no
563
+ // cleanup.md to explain it (unrecoverable: revert refuses on a missing
564
+ // tombstone, and nothing else knows this archive dir exists). Reordered,
565
+ // a crash here instead leaves, at worst, a tombstone describing a move
566
+ // that has not happened yet, with the file still at its original
567
+ // location — the ordinary "nothing archived yet" state every caller
568
+ // already handles.
493
569
  fs.writeFileSync(auditPath, auditAsset, "utf8");
494
570
  recordWrittenPath(auditPath);
571
+ fs.renameSync(filePath, archivedPath);
572
+ // #652: an archive is a delete + a create. BOTH ends are journaled so the
573
+ // sync stages the removal of the original alongside the archived copy.
574
+ recordWrittenPath(filePath);
575
+ recordWrittenPath(archivedPath);
495
576
  return {
496
577
  ref: candidate.ref,
497
- parentRef: candidate.parentRef,
578
+ ...(candidate.parentRef ? { parentRef: candidate.parentRef } : {}),
498
579
  reason: candidate.reason,
499
580
  beliefState: "archived",
500
- previousBeliefState: priorBeliefStateForArchive(candidate),
581
+ previousBeliefState,
501
582
  ...(candidate.survivorRef ? { survivorRef: candidate.survivorRef } : {}),
502
583
  originalPath,
503
584
  archivedPath: archiveRef,
504
585
  auditPath: auditRef,
505
586
  archivedAt,
587
+ ...(retiring ? { proposalId: candidate.proposalId, retiredAt: archivedAt } : {}),
588
+ ...(retiring && candidate.successorRefs && candidate.successorRefs.length > 0
589
+ ? { successorRefs: candidate.successorRefs }
590
+ : {}),
506
591
  };
507
592
  }
593
+ /**
594
+ * How long a retirement's archived bytes stay on disk after `retiredAt`
595
+ * before the purge sweep deletes them. Git history keeps the bytes (D27;
596
+ * plan §5.4 "Purge").
597
+ */
598
+ export const RETIRE_GRACE_DAYS = 30;
599
+ const RETIRE_GRACE_MS = RETIRE_GRACE_DAYS * 24 * 60 * 60 * 1000;
600
+ /** The one file every archive dir keeps forever — never deleted by the purge sweep. */
601
+ const TOMBSTONE_FILENAME = "cleanup.md";
602
+ const EMPTY_ARCHIVE_PURGE_RESULT = { purgedDirs: 0, purgedFiles: 0 };
603
+ /**
604
+ * The purge sweep (0.9.17-alpha.9 plan §5.4, §8 step 8): deterministic, no
605
+ * LLM, run once at improve-run start. Deletes the archived asset bytes —
606
+ * never `cleanup.md` — of every retirement whose tombstone `retiredAt` is
607
+ * more than {@link RETIRE_GRACE_DAYS} old AND whose archived files are all
608
+ * git-tracked and clean at the time of the sweep (see below). Git history
609
+ * keeps the bytes (D27); the tombstone, and ref resolution through it
610
+ * (`core/asset/memory-archive.ts`), are unaffected — only the tombstone's
611
+ * own `originalPath` file(s) are removed.
612
+ *
613
+ * Git-backed bundles only: a bundle with no `.git` of its own has no history
614
+ * to fall back on, so its archive is left untouched (`akm health` reports
615
+ * its size instead — see `health/archive-usage.ts`). Every deleted path is
616
+ * journaled (`recordWrittenPath`) so the end-of-run sync commits the
617
+ * removal, the same way it commits the archive move itself (#652).
618
+ *
619
+ * `.git` presence is necessary but NOT sufficient: `proposal accept` only
620
+ * commits for a `kind: "git"` write target (`core/write-source.ts`
621
+ * `commitWriteTargetBoundary`), and improve's own auto-sync stages only the
622
+ * paths its own run wrote. A filesystem-kind bundle that merely happens to
623
+ * have a `.git` directory (e.g. the owner committing by hand, or an old
624
+ * repo that was never configured as a git source) can carry retirements
625
+ * that were archived but never committed — deleting those would lose the
626
+ * only surviving copy. So every archived file under a directory past grace
627
+ * is checked against `git ls-files` (tracked), `git status --porcelain
628
+ * -uall` (clean), and `git ls-files -v` (verifiable — an assume-unchanged
629
+ * or skip-worktree file hides its own edits from `git status`, so it is
630
+ * never trusted as clean either) — each computed ONCE per sweep, not per
631
+ * directory; a directory with even one untracked, modified, or
632
+ * unverifiable file (tombstone included) is left whole for a later sweep.
633
+ * If any of those three git calls itself fails (a broken submodule can fail
634
+ * `git status` while `git ls-files` still succeeds, or git can be missing
635
+ * from `PATH` entirely), the whole sweep purges nothing and warns once —
636
+ * an empty result from a FAILED check is never treated the same as a
637
+ * verified-empty one.
638
+ *
639
+ * A memory-cleanup family-prune archive (not a retire proposal's) carries no
640
+ * `retiredAt` in its tombstone at all, so it is never a candidate here —
641
+ * this sweep only ever touches retirements, never that older archive class.
642
+ */
643
+ export function purgeGracedArchive(stashDir, now = new Date()) {
644
+ if (!isGitBackedStash(stashDir))
645
+ return EMPTY_ARCHIVE_PURGE_RESULT;
646
+ const archiveRoot = path.join(stashDir, MEMORY_ARCHIVE_REL);
647
+ let entries;
648
+ try {
649
+ entries = fs.readdirSync(archiveRoot, { withFileTypes: true });
650
+ }
651
+ catch {
652
+ return EMPTY_ARCHIVE_PURGE_RESULT; // no archive yet
653
+ }
654
+ const cutoffMs = now.getTime() - RETIRE_GRACE_MS;
655
+ // One git inspection per sweep, not per directory. All three sets are
656
+ // repo-relative POSIX paths, matched below against each archived file's
657
+ // own repo-relative path — a file is safe to delete only if it is in
658
+ // `tracked`, NOT in `dirty`, and NOT in `unverifiable`.
659
+ //
660
+ // Each of the three git calls can itself fail independently — a broken
661
+ // submodule can make `git status` exit nonzero while `git ls-files`
662
+ // succeeds, or vice versa (round-3 review, probes G8/G9). `[]` from a
663
+ // failed call is indistinguishable from a genuinely empty result once it
664
+ // is in a Set, so this checks `ok` FIRST: any failure purges nothing this
665
+ // sweep rather than silently trusting whichever check happened to
666
+ // succeed — a `dirty`/`unverifiable` set that came back empty ONLY
667
+ // because the call failed must never read as "nothing to protect".
668
+ const dirtyQuery = tryListGitChangedPaths(stashDir);
669
+ const trackedQuery = tryListGitTrackedPaths(stashDir, MEMORY_ARCHIVE_REL);
670
+ const unverifiableQuery = tryListGitUnverifiablePaths(stashDir, MEMORY_ARCHIVE_REL);
671
+ if (!dirtyQuery.ok || !trackedQuery.ok || !unverifiableQuery.ok) {
672
+ warn(`[improve] archive purge: skipped this sweep — could not determine the archive's git state at ${stashDir} ` +
673
+ "(git status/ls-files failed); nothing was purged.");
674
+ return EMPTY_ARCHIVE_PURGE_RESULT;
675
+ }
676
+ const dirty = new Set(dirtyQuery.paths);
677
+ const tracked = new Set(trackedQuery.paths);
678
+ // G10: assume-unchanged / skip-worktree files never show up as dirty even
679
+ // when genuinely modified — treated the same as "not tracked" below, so
680
+ // such a file (and its whole retirement) is left for a later sweep.
681
+ const unverifiable = new Set(unverifiableQuery.paths);
682
+ let purgedDirs = 0;
683
+ let purgedFiles = 0;
684
+ for (const entry of entries) {
685
+ // `Dirent.isDirectory()` reflects `lstat`, so it is false for a symlink
686
+ // even when the symlink points at a directory — a symlinked
687
+ // `archive/<name>` is skipped here, never followed (N1). Everything
688
+ // below only ever joins path components onto `archiveRoot` through
689
+ // `entry.name`/`readdirSync` results, so a purge can never reach
690
+ // outside `.akm/memory-cleanup/archive/`.
691
+ if (!entry.isDirectory())
692
+ continue;
693
+ const dir = path.join(archiveRoot, entry.name);
694
+ let data;
695
+ try {
696
+ data = parseFrontmatter(fs.readFileSync(path.join(dir, TOMBSTONE_FILENAME), "utf8")).data;
697
+ }
698
+ catch {
699
+ continue; // not a tombstone dir, or unreadable — never guess
700
+ }
701
+ const retiredAt = data.retiredAt;
702
+ if (typeof retiredAt !== "string")
703
+ continue; // family-prune archive, not a retirement — out of scope
704
+ const retiredMs = Date.parse(retiredAt);
705
+ if (!Number.isFinite(retiredMs) || retiredMs >= cutoffMs)
706
+ continue; // "more than" the grace period — exactly at it is not enough
707
+ const allFiles = listFilesRecursive(dir); // tombstone included — the whole entry must be a clean, committed unit
708
+ const isSafeToPurge = allFiles.every((filePath) => {
709
+ const key = toPosix(path.relative(stashDir, filePath));
710
+ return tracked.has(key) && !dirty.has(key) && !unverifiable.has(key);
711
+ });
712
+ if (!isSafeToPurge)
713
+ continue; // untracked, modified, or unverifiable entry — skip the whole directory this sweep (B1, G10)
714
+ let children;
715
+ try {
716
+ children = fs.readdirSync(dir);
717
+ }
718
+ catch {
719
+ continue;
720
+ }
721
+ let purgedAnyInThisDir = false;
722
+ for (const child of children) {
723
+ if (child === TOMBSTONE_FILENAME)
724
+ continue;
725
+ const childPath = path.join(dir, child);
726
+ // The archived original path may be nested (e.g. `memories/sub/foo.md`
727
+ // under this dir) — the journal (like git) tracks FILES, so every leaf
728
+ // under childPath is recorded individually, not the directory itself.
729
+ const filesUnderChild = listFilesRecursive(childPath);
730
+ try {
731
+ fs.rmSync(childPath, { recursive: true, force: true });
732
+ for (const filePath of filesUnderChild)
733
+ recordWrittenPath(filePath);
734
+ purgedFiles += filesUnderChild.length;
735
+ purgedAnyInThisDir = purgedAnyInThisDir || filesUnderChild.length > 0;
736
+ }
737
+ catch {
738
+ // Best-effort: a locked or already-gone entry is skipped, not fatal to the run.
739
+ }
740
+ }
741
+ if (purgedAnyInThisDir)
742
+ purgedDirs++;
743
+ }
744
+ return { purgedDirs, purgedFiles };
745
+ }
746
+ /** Every file under `target` (itself included if it's a file), for individual journaling before a recursive delete. */
747
+ function listFilesRecursive(target) {
748
+ let stat;
749
+ try {
750
+ stat = fs.lstatSync(target);
751
+ }
752
+ catch {
753
+ return [];
754
+ }
755
+ if (!stat.isDirectory())
756
+ return stat.isFile() ? [target] : [];
757
+ let children;
758
+ try {
759
+ children = fs.readdirSync(target);
760
+ }
761
+ catch {
762
+ return [];
763
+ }
764
+ return children.flatMap((child) => listFilesRecursive(path.join(target, child)));
765
+ }
508
766
  function persistBeliefStateTransition(filePath, transition) {
509
767
  mutateFrontmatter(filePath, (parsed) => {
510
768
  const nextFrontmatter = {
@@ -114,11 +114,6 @@ export function buildImproveExecutionPlan(input) {
114
114
  wouldRun: input.stageConfig.extract.enabled,
115
115
  reason: input.stageConfig.extract.reason,
116
116
  },
117
- {
118
- name: "graph-extraction",
119
- wouldRun: input.stageConfig.graphExtraction.enabled,
120
- reason: input.stageConfig.graphExtraction.reason,
121
- },
122
117
  {
123
118
  name: "memory-inference",
124
119
  wouldRun: input.stageConfig.memoryInference.enabled,
@@ -9,10 +9,9 @@
9
9
  * Candidate selection reads the improve ledger plus one set of signals: a ref
10
10
  * is eligible for a source when feedback newer than its last attempt landed and
11
11
  * no ledger window holds it. Refs without recent feedback can still be picked
12
- * by the fallback lanes (proactive maintenance, high salience, forgetting
13
- * safety); the survivors are ranked by salience, checked on disk and capped.
14
- * A plan-only run evaluates the same selectors against read snapshots and
15
- * writes nothing.
12
+ * by the fallback lanes (proactive maintenance, high salience); the survivors
13
+ * are ranked by salience, checked on disk and capped. A plan-only run
14
+ * evaluates the same selectors against read snapshots and writes nothing.
16
15
  */
17
16
  import fs from "node:fs";
18
17
  import path from "node:path";
@@ -36,13 +35,13 @@ import { computeSafeChunkSize, DEFAULT_CONTEXT_LENGTH_TOKENS } from "./consolida
36
35
  import { assetTypeOf, buildUtilityMap, dedupeRefs, findAssetFilePath, isDistillCandidateRef, isLessonCandidate, resolveImproveScope, withIndexDb, } from "./eligibility.js";
37
36
  import { akmExtract, countNewExtractCandidates } from "./extract.js";
38
37
  import { computeValenceScore } from "./feedback-valence.js";
39
- import { isLedgerBlocked, lastAttemptByRef, ledgerRowFor, loadLedgerSnapshot, stateKey, stripBundle, } from "./ledger.js";
38
+ import { isLedgerBlocked, lastAttemptByRef, ledgerRowFor, loadLedgerSnapshot, stateKey, } from "./ledger.js";
40
39
  import { applyMemoryCleanup } from "./memory/memory-improve.js";
41
40
  import { getAllAssetOutcomes, getAssetOutcome, getOutcomeScoresByRef, OUTCOME_SCORE_MAX, outcomeScoreToSalience, projectAssetOutcome, updateAssetOutcome, } from "./outcome-loop.js";
42
41
  import { projectMemoryCleanup, selectEffectiveImproveRefs } from "./planner.js";
43
42
  import { DEFAULT_DUE_DAYS, DEFAULT_MAX_PER_RUN, selectProactiveMaintenanceRefs } from "./proactive-maintenance.js";
44
43
  import { isInRetrievalScope, loadRetrievalScope } from "./retrieval-scope.js";
45
- import { buildRankChangeReport, computeSalience, getAllRankScores, getAssetSalience, getLastUseMsByRef, isContentEncodingRow, SALIENCE_NO_OP_DAMPEN_FACTOR, SALIENCE_NO_OP_DAMPEN_THRESHOLD, upsertAssetSalience, } from "./salience.js";
44
+ import { computeSalience, getAssetSalience, getLastUseMsByRef, isContentEncodingRow, SALIENCE_NO_OP_DAMPEN_FACTOR, SALIENCE_NO_OP_DAMPEN_THRESHOLD, upsertAssetSalience, } from "./salience.js";
46
45
  import { attributeStage, errMessage } from "./stage.js";
47
46
  /** The candidate's durable state key (salience, outcome, ledger). */
48
47
  const keyOf = (r) => stateKey(r.ref, r.itemRef);
@@ -79,13 +78,7 @@ export function pickDefined(source, keys) {
79
78
  out[key] = source[key];
80
79
  return out;
81
80
  }
82
- export const CONSOLIDATION_CONFIG_KEYS = [
83
- "enabled",
84
- "minPoolSize",
85
- "limit",
86
- "maxChunkSize",
87
- "incrementalSince",
88
- ];
81
+ export const CONSOLIDATION_CONFIG_KEYS = ["enabled", "minPoolSize", "limit", "maxChunkSize"];
89
82
  /** Emit an aggregate `improve_skipped` row (never one per ref). */
90
83
  export function recordImproveSkip(eventsCtx, ref, metadata) {
91
84
  appendEvent({ eventType: "improve_skipped", ref, metadata }, eventsCtx);
@@ -120,8 +113,6 @@ function planConsolidationPass(args) {
120
113
  writeTarget: options.writeTarget,
121
114
  target: options.target,
122
115
  limit: processConfig?.limit,
123
- incrementalSince: processConfig?.incrementalSince,
124
- neighborsPerChanged: processConfig?.neighborsPerChanged,
125
116
  maxChunkSize: processConfig?.maxChunkSize,
126
117
  }, primaryStashDir, [], args.existingKnowledgeBodyHashes ?? loadExistingKnowledgeBodyHashes(primaryStashDir), { readOnly: args.eventsCtx?.readOnly === true })
127
118
  : { poolSize: 0, candidatePoolSize: 0, judgedUnchanged: 0 };
@@ -215,8 +206,6 @@ async function runConsolidationPass(args) {
215
206
  existingKnowledgeBodyHashes,
216
207
  sourceRun: `consolidate-${Date.now()}`,
217
208
  limit: processConfig?.limit,
218
- incrementalSince: processConfig?.incrementalSince,
219
- neighborsPerChanged: processConfig?.neighborsPerChanged,
220
209
  maxChunkSize: processConfig?.maxChunkSize,
221
210
  signal: args.budgetSignal,
222
211
  p90ChunkSecondsDefault: processConfig?.p90ChunkSecondsDefault,
@@ -648,8 +637,8 @@ export function partitionBySignalDelta(args) {
648
637
  }
649
638
  /**
650
639
  * Pick the loop's refs: signal delta, the fallback lanes (unless
651
- * `--require-feedback-signal`), lane attribution, salience and forgetting
652
- * safety, the no-op-dampened ranking, the disk check and the limit.
640
+ * `--require-feedback-signal`), lane attribution, salience, the
641
+ * no-op-dampened ranking, the disk check and the limit.
653
642
  */
654
643
  async function selectLoopCandidates(args, postCleanupRefs, validationFailureRefs, actions, persist) {
655
644
  const { scope, options, primaryStashDir, eventsCtx, improveProfile } = args;
@@ -664,9 +653,9 @@ async function selectLoopCandidates(args, postCleanupRefs, validationFailureRefs
664
653
  const processableRefs = [...partition.eligibleRefs, ...partition.distillOnlyRefs];
665
654
  const signalFiltered = processableRefs.filter((c) => snapshot.feedback.get(c.ref)?.hasSignal === true);
666
655
  const signalBearingSet = new Set(signalFiltered.map((r) => r.ref));
667
- // The fallback lanes (proactive, high salience, forgetting safety) have no
668
- // usage evidence of their own: they pick only what retrieval returned or new
669
- // material improve never processed (#986). Evaluated once per candidate.
656
+ // The fallback lanes (proactive, high salience) have no usage evidence of
657
+ // their own: they pick only what retrieval returned or new material improve
658
+ // never processed (#986). Evaluated once per candidate.
670
659
  const fallbackEligible = postCleanupRefs.filter((c) => !validationFailureRefs.has(c.ref));
671
660
  const allowFallbacks = options.requireFeedbackSignal !== true;
672
661
  const retrievalScope = scope.mode === "ref" || !allowFallbacks
@@ -689,7 +678,7 @@ async function selectLoopCandidates(args, postCleanupRefs, validationFailureRefs
689
678
  : [];
690
679
  // An explicit ref scope always acts on its ref; otherwise usage signals gate the pool.
691
680
  const signalAndRetrievalRefs = dedupeRefs([...signalFiltered, ...proactive.proactiveRefs, ...highSalienceRefs]);
692
- let mergedRefs = scope.mode === "ref" ? processableRefs : options.requireFeedbackSignal ? signalFiltered : signalAndRetrievalRefs;
681
+ const mergedRefs = scope.mode === "ref" ? processableRefs : options.requireFeedbackSignal ? signalFiltered : signalAndRetrievalRefs;
693
682
  // Lane attribution, weakest first so the strongest wins: high-salience <
694
683
  // proactive < signal-delta, and an explicit ref scope over everything.
695
684
  const sourceByRef = new Map();
@@ -704,22 +693,7 @@ async function selectLoopCandidates(args, postCleanupRefs, validationFailureRefs
704
693
  sourceByRef.set(r.ref, "scope");
705
694
  for (const r of mergedRefs)
706
695
  r.eligibilitySource = sourceByRef.get(r.ref) ?? "unknown";
707
- // Forgetting safety may only reuse this plan's own surviving objects inside
708
- // the retrieval scope, and never a ref whose reflect window is still open.
709
- const forgettingEligible = fallbackEligible.filter((c) => !unscoped.has(c.ref) &&
710
- !isLedgerBlocked(ledgerRowFor(snapshot.ledger, "reflect", c.ref, c.itemRef), snapshot.nowIso));
711
- const scored = scoreSalience(args, mergedRefs, snapshot.feedback, retrieval.retrievalCounts, persist);
712
- mergedRefs = applyForgettingSafety({
713
- pendingForgettingRefs: scored.pendingForgettingRefs,
714
- scope,
715
- mergedRefs,
716
- eligibleRefs: forgettingEligible,
717
- allowFallbacks,
718
- eligibilitySourceByRef: sourceByRef,
719
- highSalienceRefs,
720
- proactiveRefs: proactive.proactiveRefs,
721
- signalFiltered,
722
- });
696
+ const salienceMap = scoreSalience(args, mergedRefs, snapshot.feedback, retrieval.retrievalCounts, persist);
723
697
  // Rank by salience; a ref skipped as a no-op repeatedly sorts lower (its stored rank is untouched).
724
698
  const noOps = new Map();
725
699
  withRunState(eventsCtx, persist, (db) => {
@@ -727,7 +701,7 @@ async function selectLoopCandidates(args, postCleanupRefs, validationFailureRefs
727
701
  noOps.set(r.ref, getAssetSalience(db, keyOf(r))?.consecutive_no_ops ?? 0);
728
702
  });
729
703
  const effectiveScore = (ref) => {
730
- const rank = scored.salienceMap.get(ref)?.rankScore ?? 0;
704
+ const rank = salienceMap.get(ref)?.rankScore ?? 0;
731
705
  return (noOps.get(ref) ?? 0) >= SALIENCE_NO_OP_DAMPEN_THRESHOLD ? rank * SALIENCE_NO_OP_DAMPEN_FACTOR : rank;
732
706
  };
733
707
  const sorted = [...mergedRefs].sort((a, b) => effectiveScore(b.ref) - effectiveScore(a.ref) || (a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0));
@@ -907,8 +881,7 @@ function selectHighSalienceLane(options, improveProfile, eventsCtx, candidates,
907
881
  /**
908
882
  * Score the merged refs: update `asset_outcome` (projected on a plan-only
909
883
  * run), compute each salience vector (keeping a stored content-derived
910
- * encoding score), then persist and compare the stash-wide ranking. A ref that
911
- * falls from the top 200 to below 500 becomes a forgetting-safety candidate.
884
+ * encoding score), then persist it.
912
885
  */
913
886
  function scoreSalience(args, mergedRefs, feedback, retrievalCounts, persist) {
914
887
  const { options, eventsCtx } = args;
@@ -950,58 +923,13 @@ function scoreSalience(args, mergedRefs, feedback, retrievalCounts, persist) {
950
923
  outcomeWeightEnabled,
951
924
  }));
952
925
  }
953
- const refByKey = new Map(mergedRefs.map((r) => [keyOf(r), r.ref]));
954
- const pendingForgettingRefs = withRunState(eventsCtx, persist, (db) => {
955
- // Positions are stash-wide: every stored row of this source, with this
956
- // run's scores overlaid under the same keys.
957
- const before = new Map();
958
- for (const [ref, score] of getAllRankScores(db)) {
959
- const boundary = ref.indexOf("//");
960
- if (options.sourceName && (boundary >= 0 ? ref.slice(0, boundary) : undefined) !== options.sourceName)
961
- continue;
962
- before.set(ref, score);
963
- }
964
- let forgetting = [];
965
- if (before.size > 0) {
966
- const after = new Map(before);
967
- for (const r of mergedRefs)
968
- after.set(keyOf(r), salienceMap.get(r.ref)?.rankScore ?? 0);
969
- const report = buildRankChangeReport(toRankPositions(before), toRankPositions(after));
970
- if (report.forgettingCandidates.length > 0) {
971
- const drops = report.forgettingCandidates
972
- .slice(0, 5)
973
- .map((e) => `${e.ref} (#${e.oldRank}→#${e.newRank})`)
974
- .join(", ");
975
- warn(`[improve/salience] WS-1 rank-change report: ${report.forgettingCandidates.length} asset(s) fell from top-200 to below position 500. Top drops: ${drops}`);
976
- forgetting = report.forgettingCandidates.map((e) => refByKey.get(e.ref) ?? e.ref);
977
- }
978
- if (persist) {
979
- appendEvent({
980
- eventType: "improve_salience_rank_change",
981
- ref: undefined,
982
- metadata: {
983
- stashSize: before.size,
984
- totalChanged: report.allChanges.length,
985
- forgettingCandidates: report.forgettingCandidates.length,
986
- topDrops: report.forgettingCandidates
987
- .slice(0, 10)
988
- .map((e) => ({ ref: e.ref, oldRank: e.oldRank, newRank: e.newRank })),
989
- },
990
- }, eventsCtx);
991
- }
992
- }
993
- if (persist) {
926
+ if (persist) {
927
+ withRunState(eventsCtx, persist, (db) => {
994
928
  for (const r of mergedRefs)
995
929
  upsertAssetSalience(db, keyOf(r), salienceMap.get(r.ref), now);
996
- }
997
- return forgetting;
998
- }) ?? [];
999
- return { salienceMap, pendingForgettingRefs };
1000
- }
1001
- /** 1-indexed positions by score desc (ref asc on ties). */
1002
- function toRankPositions(scores) {
1003
- const sorted = [...scores.entries()].sort(([refA, a], [refB, b]) => b !== a ? b - a : refA < refB ? -1 : refA > refB ? 1 : 0);
1004
- return new Map(sorted.map(([ref], i) => [ref, i + 1]));
930
+ });
931
+ }
932
+ return salienceMap;
1005
933
  }
1006
934
  /**
1007
935
  * Update each ref's outcome row and return its outcome salience, normalized
@@ -1086,49 +1014,6 @@ function updateOutcomeScores(args) {
1086
1014
  });
1087
1015
  return out;
1088
1016
  }
1089
- /**
1090
- * Forgetting safety: inject this plan's own candidates that fell out of the
1091
- * top ranks, past the signal gate (never for a ref scope or with
1092
- * `--require-feedback-signal`). Attribution afterwards: high-salience <
1093
- * proactive < forgetting-safety < signal-delta.
1094
- */
1095
- export function applyForgettingSafety(args) {
1096
- const { eligibilitySourceByRef } = args;
1097
- let mergedRefs = args.mergedRefs;
1098
- if (args.pendingForgettingRefs.length === 0 || args.scope.mode === "ref" || !args.allowFallbacks)
1099
- return mergedRefs;
1100
- const present = new Set(mergedRefs.map((r) => r.ref));
1101
- const byRef = new Map(args.eligibleRefs.map((c) => [c.ref, c]));
1102
- const byItemRef = new Map(args.eligibleRefs.flatMap((c) => (c.itemRef ? [[c.itemRef, c]] : [])));
1103
- const added = [];
1104
- const forgetting = new Set();
1105
- for (const stored of args.pendingForgettingRefs) {
1106
- // A qualified spelling must match this plan's exact item_ref.
1107
- const candidate = byItemRef.get(stored) ?? (stored.includes("//") ? undefined : byRef.get(stripBundle(stored)));
1108
- if (!candidate || forgetting.has(candidate.ref))
1109
- continue;
1110
- forgetting.add(candidate.ref);
1111
- if (!present.has(candidate.ref)) {
1112
- added.push(candidate);
1113
- present.add(candidate.ref);
1114
- }
1115
- }
1116
- if (added.length > 0)
1117
- mergedRefs = dedupeRefs([...mergedRefs, ...added]);
1118
- if (forgetting.size === 0)
1119
- return mergedRefs;
1120
- for (const r of args.highSalienceRefs)
1121
- eligibilitySourceByRef.set(r.ref, "high-salience");
1122
- for (const r of args.proactiveRefs)
1123
- eligibilitySourceByRef.set(r.ref, "proactive");
1124
- for (const ref of forgetting)
1125
- eligibilitySourceByRef.set(ref, "forgetting-safety");
1126
- for (const r of args.signalFiltered)
1127
- eligibilitySourceByRef.set(r.ref, "signal-delta");
1128
- for (const r of mergedRefs)
1129
- r.eligibilitySource = eligibilitySourceByRef.get(r.ref) ?? "unknown";
1130
- return mergedRefs;
1131
- }
1132
1017
  /** Drop candidates whose file vanished since planning, with one aggregate event. */
1133
1018
  async function dropRefsMissingOnDisk(sorted, options, eventsCtx, persist) {
1134
1019
  const actionableRefs = [];
@@ -7,8 +7,8 @@
7
7
  *
8
8
  * Fresh feedback and an explicit `--scope <ref>` are usage evidence of their
9
9
  * own, so the signal-delta and scope lanes need no check. The fallback lanes
10
- * (proactive maintenance, high salience, forgetting safety) and consolidation
11
- * pick assets without such evidence, so they pick only inside this scope.
10
+ * (proactive maintenance, high salience) and consolidation pick assets
11
+ * without such evidence, so they pick only inside this scope.
12
12
  */
13
13
  import fs from "node:fs";
14
14
  import { daysToMs } from "../../core/common.js";
@@ -16,7 +16,7 @@ import { warn } from "../../core/warn.js";
16
16
  import { listUsedEntryRefs, USAGE_EVENT_RETENTION_DAYS } from "../../indexer/usage/usage-events.js";
17
17
  import { listImproveLedgerRows } from "../../storage/repositories/improve-ledger-repository.js";
18
18
  import { listProposalRefSources } from "../../storage/repositories/proposals-repository.js";
19
- import { readLedgerDb, stripBundle } from "./ledger.js";
19
+ import { PAIR_PASS_LEDGER_SOURCE, readLedgerDb, stripBundle } from "./ledger.js";
20
20
  /**
21
21
  * Ledger and proposal sources that bring material in. Every other source is an
22
22
  * improve stage reworking an asset (reflect, distill, consolidate, schema repair).
@@ -43,7 +43,22 @@ export function loadRetrievalScope(access, stashDir) {
43
43
  }
44
44
  if (!stashDir)
45
45
  return;
46
- for (const row of [...listImproveLedgerRows(db, stashDir), ...listProposalRefSources(db, stashDir)]) {
46
+ // Blocker 1 (second review round): a pair-pass ledger row must NOT mark
47
+ // an asset "processed" — unlike every other stage, the pair pass judges
48
+ // material against its NEIGHBOURS, not on its own merits, so its own
49
+ // attempt is not usage evidence the fallback lanes (proactive,
50
+ // high-salience) or promotion retries should be starved by. Left in
51
+ // the ledger for the pair pass's OWN eligibility
52
+ // (selectInitiators reads content_hash directly, never this scope).
53
+ // Proposal rows are untouched: a MINTED retire proposal is real
54
+ // evidence something happened to the asset.
55
+ for (const row of listImproveLedgerRows(db, stashDir)) {
56
+ if (row.source === PAIR_PASS_LEDGER_SOURCE)
57
+ continue;
58
+ if (!CAPTURE_SOURCES.has(row.source))
59
+ processed.add(stripBundle(row.ref));
60
+ }
61
+ for (const row of listProposalRefSources(db, stashDir)) {
47
62
  if (!CAPTURE_SOURCES.has(row.source))
48
63
  processed.add(stripBundle(row.ref));
49
64
  }