akm-cli 0.9.0-beta.1 → 0.9.0-beta.11

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 (56) hide show
  1. package/CHANGELOG.md +492 -0
  2. package/dist/assets/templates/html/default.html +78 -0
  3. package/dist/assets/templates/html/health.html +732 -0
  4. package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
  5. package/dist/cli/shared.js +21 -5
  6. package/dist/cli.js +47 -5
  7. package/dist/commands/config-cli.js +0 -10
  8. package/dist/commands/feedback-cli.js +42 -37
  9. package/dist/commands/graph/graph.js +75 -71
  10. package/dist/commands/health/checks.js +48 -0
  11. package/dist/commands/health/html-report.js +666 -0
  12. package/dist/commands/health.js +201 -14
  13. package/dist/commands/improve/consolidate.js +39 -6
  14. package/dist/commands/improve/distill.js +26 -5
  15. package/dist/commands/improve/extract-prompt.js +1 -1
  16. package/dist/commands/improve/extract.js +73 -8
  17. package/dist/commands/improve/improve-auto-accept.js +63 -3
  18. package/dist/commands/improve/improve-cli.js +7 -0
  19. package/dist/commands/improve/improve-profiles.js +4 -0
  20. package/dist/commands/improve/improve.js +874 -447
  21. package/dist/commands/improve/proactive-maintenance.js +113 -0
  22. package/dist/commands/improve/reflect-noise.js +0 -0
  23. package/dist/commands/improve/reflect.js +31 -0
  24. package/dist/commands/proposal/drain.js +73 -6
  25. package/dist/commands/proposal/proposal-cli.js +22 -10
  26. package/dist/commands/proposal/proposal.js +17 -1
  27. package/dist/commands/proposal/validators/proposals.js +365 -329
  28. package/dist/commands/read/curate.js +17 -0
  29. package/dist/commands/remember.js +6 -2
  30. package/dist/commands/sources/stash-cli.js +10 -2
  31. package/dist/commands/tasks/tasks.js +32 -8
  32. package/dist/core/config/config-schema.js +33 -0
  33. package/dist/core/logs-db.js +304 -0
  34. package/dist/core/paths.js +3 -0
  35. package/dist/core/state-db.js +152 -14
  36. package/dist/indexer/db/db.js +99 -13
  37. package/dist/indexer/ensure-index.js +152 -17
  38. package/dist/indexer/index-writer-lock.js +99 -0
  39. package/dist/indexer/indexer.js +114 -111
  40. package/dist/indexer/passes/memory-inference.js +61 -22
  41. package/dist/integrations/harnesses/claude/session-log.js +17 -5
  42. package/dist/llm/client.js +38 -4
  43. package/dist/llm/usage-persist.js +77 -0
  44. package/dist/llm/usage-telemetry.js +103 -0
  45. package/dist/output/context.js +3 -2
  46. package/dist/output/html-render.js +73 -0
  47. package/dist/output/shapes/helpers.js +17 -1
  48. package/dist/output/text/helpers.js +69 -1
  49. package/dist/scripts/migrate-storage.js +154 -25
  50. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +21 -2
  51. package/dist/sources/providers/tar-utils.js +16 -8
  52. package/dist/tasks/backends/cron.js +46 -9
  53. package/dist/tasks/runner.js +99 -16
  54. package/dist/workflows/db.js +4 -0
  55. package/package.json +3 -2
  56. package/dist/commands/config-edit.js +0 -344
@@ -4,11 +4,13 @@
4
4
  import fs from "node:fs";
5
5
  import { ConfigError, UsageError } from "../core/errors.js";
6
6
  import { appendEvent, readEvents } from "../core/events.js";
7
+ import { buildTaskRunId, getLoggedRunIds, openLogsDatabase } from "../core/logs-db.js";
7
8
  import { getStateDbPathInDataDir } from "../core/paths.js";
8
9
  import { listExistingTableNames, openStateDatabase, queryCompletedTaskIntervals, queryImproveRuns, queryTaskHistory, } from "../core/state-db.js";
9
10
  import { parseSinceToIso } from "../core/time.js";
10
11
  import { readSemanticStatus } from "../indexer/search/semantic-status.js";
11
12
  import { getExecutionLogCandidates } from "../integrations/session-logs/index.js";
13
+ import { LLM_USAGE_EVENT } from "../llm/usage-persist.js";
12
14
  import { HEALTH_CHECKS } from "./health/checks.js";
13
15
  const DEFAULT_SINCE_MS = 24 * 60 * 60 * 1000;
14
16
  const IMPROVE_COMPLETED_EVENT = "improve_completed";
@@ -471,7 +473,10 @@ function finalizeImproveMetrics(metrics) {
471
473
  */
472
474
  function mergeImproveMetrics(dst, src) {
473
475
  dst.plannedRefs += src.plannedRefs;
474
- dst.profileFilteredRefs += src.profileFilteredRefs;
476
+ // profileFilteredRefs is the count of refs the planner drops up-front for the
477
+ // active profile — recomputed against the (stable) stash every run, so it is a
478
+ // snapshot, NOT a per-run increment. Summing it re-counts the same refs each
479
+ // run (the ~2.4M bug). Set from the most recent run in summarizeImproveRuns.
475
480
  dst.actions.reflect.ok += src.actions.reflect.ok;
476
481
  dst.actions.reflect.failed += src.actions.reflect.failed;
477
482
  dst.actions.reflect.cooldown += src.actions.reflect.cooldown;
@@ -504,8 +509,10 @@ function mergeImproveMetrics(dst, src) {
504
509
  dst.coverageGapCount += src.coverageGapCount;
505
510
  dst.evalCasesWritten += src.evalCasesWritten;
506
511
  dst.deadUrlCount += src.deadUrlCount;
507
- dst.memorySummary.eligible += src.memorySummary.eligible;
508
- dst.memorySummary.derived += src.memorySummary.derived;
512
+ // NOTE: memorySummary (derived/eligible) is a WHOLE-STASH snapshot recorded on
513
+ // every run, NOT a per-run increment — summing it across the window inflates
514
+ // it ~N× (the 1.2M-eligible bug). It is set from the most recent run in
515
+ // summarizeImproveRuns instead, so it is intentionally not merged here.
509
516
  dst.memoryCleanup.pruneCandidates += src.memoryCleanup.pruneCandidates;
510
517
  dst.memoryCleanup.contradictionCandidates += src.memoryCleanup.contradictionCandidates;
511
518
  dst.memoryCleanup.beliefStateTransitions += src.memoryCleanup.beliefStateTransitions;
@@ -566,6 +573,11 @@ function summarizeImproveRuns(db, since, until) {
566
573
  memoryInference: [],
567
574
  graphExtraction: [],
568
575
  };
576
+ // memorySummary is a whole-stash snapshot per run, so the window value is the
577
+ // MOST RECENT run's snapshot (current state) — not a sum across runs.
578
+ let latestStartMs = Number.NEGATIVE_INFINITY;
579
+ let latestMemorySummary;
580
+ let latestProfileFilteredRefs = 0;
569
581
  for (const row of rows) {
570
582
  let result;
571
583
  try {
@@ -576,6 +588,12 @@ function summarizeImproveRuns(db, since, until) {
576
588
  }
577
589
  const perRow = projectRunMetrics(result);
578
590
  mergeImproveMetrics(accum, perRow);
591
+ const startMs = new Date(row.started_at).getTime();
592
+ if (Number.isFinite(startMs) && startMs >= latestStartMs) {
593
+ latestStartMs = startMs;
594
+ latestMemorySummary = perRow.memorySummary;
595
+ latestProfileFilteredRefs = perRow.profileFilteredRefs;
596
+ }
579
597
  // Collect per-phase durations directly off the envelope. consolidation's
580
598
  // duration lives inside the sub-object; memoryInference and graphExtraction
581
599
  // expose top-level *DurationMs keys (`memoryInferenceDurationMs`,
@@ -592,6 +610,9 @@ function summarizeImproveRuns(db, since, until) {
592
610
  phaseDurations.graphExtraction.push(graphMs);
593
611
  }
594
612
  finalizeImproveMetrics(accum);
613
+ if (latestMemorySummary)
614
+ accum.memorySummary = latestMemorySummary;
615
+ accum.profileFilteredRefs = latestProfileFilteredRefs;
595
616
  accum.wallTime.byPhase = {
596
617
  consolidation: summarizePhaseDurations(phaseDurations.consolidation),
597
618
  memoryInference: summarizePhaseDurations(phaseDurations.memoryInference),
@@ -622,7 +643,7 @@ function summarizePhaseDurations(samples) {
622
643
  * Project an improve_runs row + wall-time lookup into a single ImproveRunSummary.
623
644
  * Used by `akm health --detail per-run`.
624
645
  */
625
- function projectImproveRunSummary(row, wallTimeMs) {
646
+ function projectImproveRunSummary(row, wallTimeMs, taskId) {
626
647
  let result = {};
627
648
  try {
628
649
  result = JSON.parse(row.result_json);
@@ -646,6 +667,7 @@ function projectImproveRunSummary(row, wallTimeMs) {
646
667
  mode: row.scope_mode,
647
668
  ...(row.scope_value ? { value: row.scope_value } : {}),
648
669
  },
670
+ taskId,
649
671
  actions: perRow.actions,
650
672
  memorySummary: perRow.memorySummary,
651
673
  memoryCleanup: perRow.memoryCleanup,
@@ -708,9 +730,62 @@ function findContainingTaskInterval(timestampMs, intervals) {
708
730
  }
709
731
  return undefined;
710
732
  }
733
+ /**
734
+ * Load `task_history` rows whose `task_id` begins `akm-improve` (the scheduled
735
+ * improve tasks: `akm-improve-frequent`, `akm-improve-proactive-weekly`, …) in
736
+ * the window, widened ±5 min so a task that fired just before the window opened
737
+ * still matches a run inside it. Used to attribute each improve run to the task
738
+ * that launched it.
739
+ */
740
+ function loadImproveTaskRuns(db, since, until) {
741
+ const sinceMs = new Date(since).getTime();
742
+ const untilMs = until ? new Date(until).getTime() : undefined;
743
+ const widenedSince = new Date(sinceMs - 5 * 60 * 1000).toISOString();
744
+ const widenedUntil = untilMs !== undefined ? new Date(untilMs + 5 * 60 * 1000).toISOString() : undefined;
745
+ const runs = [];
746
+ for (const row of queryTaskHistory(db, { since: widenedSince, until: widenedUntil })) {
747
+ if (!row.task_id.startsWith("akm-improve"))
748
+ continue;
749
+ const startMs = new Date(row.started_at).getTime();
750
+ if (!Number.isFinite(startMs))
751
+ continue;
752
+ const endIso = row.completed_at ?? row.failed_at;
753
+ const endMs = endIso ? new Date(endIso).getTime() : Number.NaN;
754
+ runs.push({ taskId: row.task_id, startMs, endMs });
755
+ }
756
+ return runs;
757
+ }
758
+ /**
759
+ * Attribute an improve run to the scheduled task that launched it by matching
760
+ * start times within ±5 min, scored by start delta (plus end delta when both
761
+ * ends are known). Port of the health-report skill's `match_task_id`. Returns
762
+ * `"manual"` when no scheduled improve task matches.
763
+ */
764
+ function matchImproveTaskId(startedAt, completedAt, taskRuns) {
765
+ const startMs = new Date(startedAt).getTime();
766
+ if (!Number.isFinite(startMs))
767
+ return "manual";
768
+ const endMs = completedAt ? new Date(completedAt).getTime() : Number.NaN;
769
+ let best;
770
+ let bestScore = Number.POSITIVE_INFINITY;
771
+ for (const task of taskRuns) {
772
+ const startDelta = Math.abs(task.startMs - startMs);
773
+ if (startDelta > 5 * 60 * 1000)
774
+ continue;
775
+ let score = startDelta;
776
+ if (Number.isFinite(endMs) && Number.isFinite(task.endMs))
777
+ score += Math.abs(task.endMs - endMs);
778
+ if (score < bestScore) {
779
+ bestScore = score;
780
+ best = task.taskId;
781
+ }
782
+ }
783
+ return best ?? "manual";
784
+ }
711
785
  function buildPerRunSummaries(db, since, until) {
712
786
  const rows = queryImproveRuns(db, since, until);
713
787
  const taskIntervals = loadTaskIntervals(db, since, until);
788
+ const improveTaskRuns = loadImproveTaskRuns(db, since, until);
714
789
  const summaries = [];
715
790
  for (const row of rows) {
716
791
  const startMs = new Date(row.started_at).getTime();
@@ -730,7 +805,8 @@ function buildPerRunSummaries(db, since, until) {
730
805
  const interval = Number.isFinite(startMs) ? findContainingTaskInterval(startMs, taskIntervals) : undefined;
731
806
  wallTimeMs = interval?.durationMs ?? 0;
732
807
  }
733
- summaries.push(projectImproveRunSummary(row, wallTimeMs));
808
+ const taskId = matchImproveTaskId(row.started_at, row.completed_at, improveTaskRuns);
809
+ summaries.push(projectImproveRunSummary(row, wallTimeMs, taskId));
734
810
  }
735
811
  return summaries;
736
812
  }
@@ -757,12 +833,34 @@ function computeWallTimeStats(durationsMs, byPhase) {
757
833
  };
758
834
  }
759
835
  function buildImproveSkipSummary(events) {
760
- const skipReasons = {};
836
+ // Two kinds of skip events:
837
+ // - Per-occurrence (no `count`): one event per skipped ref → SUM is correct.
838
+ // - Aggregated snapshot (carries `count`): a single per-run event whose count
839
+ // is the number of refs that hit a STABLE, whole-stash condition that run
840
+ // (`no_new_signal`, `profile_filtered_all_passes`). Each run re-counts the
841
+ // same stable set, so summing across the window re-counts it N times (the
842
+ // 2.7M / 3M inflation). For these we keep the MOST RECENT run's count — the
843
+ // current snapshot — matching how memorySummary/profileFilteredRefs are
844
+ // handled. Events arrive in chronological (offset) order, so the last
845
+ // count-bearing event per reason is the latest run's value.
846
+ const summed = {};
847
+ const latestSnapshot = {};
761
848
  for (const event of events) {
762
849
  const reason = typeof event.metadata?.reason === "string" && event.metadata.reason.trim() ? event.metadata.reason : "unknown";
763
- skipReasons[reason] = (skipReasons[reason] ?? 0) + 1;
850
+ const rawCount = event.metadata?.count;
851
+ if (typeof rawCount === "number" && Number.isFinite(rawCount) && rawCount > 0) {
852
+ latestSnapshot[reason] = rawCount; // overwrite → keeps the latest run's snapshot
853
+ }
854
+ else {
855
+ summed[reason] = (summed[reason] ?? 0) + 1;
856
+ }
764
857
  }
765
- return { skipped: events.length, skipReasons };
858
+ const skipReasons = { ...summed };
859
+ for (const [reason, count] of Object.entries(latestSnapshot)) {
860
+ skipReasons[reason] = (skipReasons[reason] ?? 0) + count;
861
+ }
862
+ const skipped = Object.values(skipReasons).reduce((a, b) => a + b, 0);
863
+ return { skipped, skipReasons };
766
864
  }
767
865
  function probeStateDbRoundTrip(stateDbPath) {
768
866
  const before = readEvents({}, { dbPath: stateDbPath }).nextOffset;
@@ -882,14 +980,84 @@ function computeDeltas(first, last) {
882
980
  }
883
981
  return out;
884
982
  }
885
- function buildWindowMetrics(db, stateDbPath, since, until, now = () => Date.now()) {
983
+ /**
984
+ * Partition task_history rows into "should have a log" (non-null log_path) and
985
+ * "log is actually backed". A run counts as backed when logs.db holds rows for
986
+ * its run_id (#579 — the DB is the primary record); rows written before logs.db
987
+ * existed fall back to the transitional on-disk file check. `logsDb` may be
988
+ * undefined when logs.db could not be opened — then only the file check runs.
989
+ */
990
+ function partitionLogBackedRows(taskRows, logsDb) {
991
+ const withLogs = taskRows.filter((row) => row.log_path !== null);
992
+ const loggedRunIds = logsDb
993
+ ? getLoggedRunIds(logsDb, withLogs.map((row) => buildTaskRunId(row.task_id, row.started_at)))
994
+ : new Set();
995
+ const backed = withLogs.filter((row) => loggedRunIds.has(buildTaskRunId(row.task_id, row.started_at)) ||
996
+ (row.log_path !== null && fs.existsSync(row.log_path)));
997
+ return { withLogs, backed };
998
+ }
999
+ /** Stage key used for `llm_usage` events recorded outside any stage scope. */
1000
+ const UNATTRIBUTED_STAGE = "unattributed";
1001
+ function emptyLlmUsageStageAggregate() {
1002
+ return {
1003
+ calls: 0,
1004
+ totalDurationMs: 0,
1005
+ promptTokens: 0,
1006
+ completionTokens: 0,
1007
+ totalTokens: 0,
1008
+ reasoningTokens: 0,
1009
+ };
1010
+ }
1011
+ function emptyLlmUsageAggregate() {
1012
+ return { ...emptyLlmUsageStageAggregate(), byStage: {} };
1013
+ }
1014
+ /**
1015
+ * Aggregate `llm_usage` events (#576) into a window total plus a per-stage
1016
+ * breakdown of call count, wall-time, and token usage. Token fields absent from
1017
+ * a best-effort record contribute 0. Calls with no `stage` land under
1018
+ * {@link UNATTRIBUTED_STAGE}.
1019
+ */
1020
+ function summarizeLlmUsage(events) {
1021
+ const aggregate = emptyLlmUsageAggregate();
1022
+ for (const event of events) {
1023
+ const meta = event.metadata ?? {};
1024
+ const stageKey = typeof meta.stage === "string" && meta.stage ? meta.stage : UNATTRIBUTED_STAGE;
1025
+ let stage = aggregate.byStage[stageKey];
1026
+ if (!stage) {
1027
+ stage = emptyLlmUsageStageAggregate();
1028
+ aggregate.byStage[stageKey] = stage;
1029
+ }
1030
+ const durationMs = toFiniteNumber(meta.durationMs);
1031
+ const promptTokens = toFiniteNumber(meta.promptTokens);
1032
+ const completionTokens = toFiniteNumber(meta.completionTokens);
1033
+ const totalTokens = toFiniteNumber(meta.totalTokens);
1034
+ const reasoningTokens = toFiniteNumber(meta.reasoningTokens);
1035
+ for (const target of [aggregate, stage]) {
1036
+ target.calls += 1;
1037
+ target.totalDurationMs += durationMs;
1038
+ target.promptTokens += promptTokens;
1039
+ target.completionTokens += completionTokens;
1040
+ target.totalTokens += totalTokens;
1041
+ target.reasoningTokens += reasoningTokens;
1042
+ }
1043
+ }
1044
+ return aggregate;
1045
+ }
1046
+ function readLlmUsageAggregate(stateDbPath, since, until) {
1047
+ const events = readEvents({ since, type: LLM_USAGE_EVENT }, { dbPath: stateDbPath }).events.filter((event) => {
1048
+ if (until === undefined)
1049
+ return true;
1050
+ return new Date(event.ts ?? since).getTime() < new Date(until).getTime();
1051
+ });
1052
+ return summarizeLlmUsage(events);
1053
+ }
1054
+ function buildWindowMetrics(db, stateDbPath, since, until, now = () => Date.now(), logsDb) {
886
1055
  const taskRows = queryTaskHistory(db, { since }).filter((row) => {
887
1056
  const startMs = new Date(row.started_at).getTime();
888
1057
  const untilMs = new Date(until).getTime();
889
1058
  return !Number.isFinite(untilMs) || startMs < untilMs;
890
1059
  });
891
- const taskRowsWithLogs = taskRows.filter((row) => row.log_path !== null);
892
- const existingLogRows = taskRowsWithLogs.filter((row) => row.log_path && fs.existsSync(row.log_path));
1060
+ const { withLogs: taskRowsWithLogs, backed: existingLogRows } = partitionLogBackedRows(taskRows, logsDb);
893
1061
  const failedTaskRows = taskRows.filter((row) => row.status === "failed");
894
1062
  const activeRows = taskRows.filter((row) => row.status === "active");
895
1063
  const stuckActiveRuns = activeRows.filter((row) => now() - new Date(row.started_at).getTime() > ACTIVE_RUN_WARN_MS).length;
@@ -923,6 +1091,7 @@ function buildWindowMetrics(db, stateDbPath, since, until, now = () => Date.now(
923
1091
  stuckActiveRuns,
924
1092
  logBackingRate: roundRate(logBackingRate),
925
1093
  probeRoundTripMs: null,
1094
+ llmUsage: readLlmUsageAggregate(stateDbPath, since, until),
926
1095
  };
927
1096
  return { improve: improveSummary, metrics, runs: runCount };
928
1097
  }
@@ -961,6 +1130,16 @@ export function akmHealth(options = {}) {
961
1130
  catch (error) {
962
1131
  throw new ConfigError(`Unable to open state.db: ${error instanceof Error ? error.message : String(error)}`, "INVALID_CONFIG_FILE");
963
1132
  }
1133
+ // logs.db backs the log-backing metric (#579). Best-effort: when it cannot
1134
+ // be opened, partitionLogBackedRows falls back to the on-disk file check, so
1135
+ // health never hard-fails on a missing/locked logs database.
1136
+ let logsDb;
1137
+ try {
1138
+ logsDb = openLogsDatabase(options.logsDbPath);
1139
+ }
1140
+ catch {
1141
+ logsDb = undefined;
1142
+ }
964
1143
  try {
965
1144
  const tables = listExistingTableNames(db, ["events", "task_history", "proposals", "schema_migrations"]);
966
1145
  const tableNames = tables.map((row) => row.name).sort();
@@ -968,8 +1147,7 @@ export function akmHealth(options = {}) {
968
1147
  const missingTables = requiredTables.filter((name) => !tableNames.includes(name));
969
1148
  const probe = probeStateDbRoundTrip(stateDbPath);
970
1149
  const taskRows = queryTaskHistory(db, { since });
971
- const taskRowsWithLogs = taskRows.filter((row) => row.log_path !== null);
972
- const existingLogRows = taskRowsWithLogs.filter((row) => row.log_path && fs.existsSync(row.log_path));
1150
+ const { withLogs: taskRowsWithLogs, backed: existingLogRows } = partitionLogBackedRows(taskRows, logsDb);
973
1151
  const failedTaskRows = taskRows.filter((row) => row.status === "failed");
974
1152
  const activeRows = taskRows.filter((row) => row.status === "active");
975
1153
  const stuckActiveRuns = activeRows.filter((row) => now() - new Date(row.started_at).getTime() > ACTIVE_RUN_WARN_MS).length;
@@ -1041,6 +1219,7 @@ export function akmHealth(options = {}) {
1041
1219
  stuckActiveRuns,
1042
1220
  logBackingRate: roundRate(logBackingRate),
1043
1221
  probeRoundTripMs: probe.durationMs,
1222
+ llmUsage: readLlmUsageAggregate(stateDbPath, since),
1044
1223
  };
1045
1224
  const hardFailure = hardChecks.some((check) => check.status === "fail");
1046
1225
  const deterministicWarnings = [...hardChecks, ...advisories].some((check) => check.status === "warn" && check.kind === "deterministic");
@@ -1062,7 +1241,7 @@ export function akmHealth(options = {}) {
1062
1241
  windowResults = windowSpecs.map((spec) => {
1063
1242
  const winSince = parseHealthSince(spec.since);
1064
1243
  const winUntil = spec.until ? parseHealthSince(spec.until) : new Date(now()).toISOString();
1065
- const bundle = buildWindowMetrics(db, stateDbPath, winSince, winUntil, now);
1244
+ const bundle = buildWindowMetrics(db, stateDbPath, winSince, winUntil, now, logsDb);
1066
1245
  return {
1067
1246
  name: spec.name,
1068
1247
  since: winSince,
@@ -1112,6 +1291,14 @@ export function akmHealth(options = {}) {
1112
1291
  }
1113
1292
  finally {
1114
1293
  db.close();
1294
+ if (logsDb) {
1295
+ try {
1296
+ logsDb.close();
1297
+ }
1298
+ catch {
1299
+ // best-effort
1300
+ }
1301
+ }
1115
1302
  }
1116
1303
  }
1117
1304
  // ── Markdown renderers ───────────────────────────────────────────────────────
@@ -809,7 +809,7 @@ export async function akmConsolidate(opts = {}) {
809
809
  };
810
810
  }
811
811
  if (opts.incrementalSince) {
812
- memories = narrowToIncrementalCandidates(memories, opts.incrementalSince, warnings);
812
+ memories = narrowToIncrementalCandidates(memories, opts.incrementalSince, warnings, opts.neighborsPerChanged);
813
813
  if (memories.length === 0) {
814
814
  return {
815
815
  schemaVersion: 1,
@@ -828,6 +828,27 @@ export async function akmConsolidate(opts = {}) {
828
828
  };
829
829
  }
830
830
  }
831
+ if (opts.limit !== undefined && memories.length > opts.limit) {
832
+ // Order oldest-modified-first before capping so the limit selects the
833
+ // stalest memories rather than a fixed head of the (rowid-ordered) DB
834
+ // query. Consolidation rewrites surviving files, bumping their mtime, so
835
+ // processed memories drift to the back of the queue and the cap rotates
836
+ // across the whole corpus over successive runs instead of revisiting the
837
+ // same slice every time. Fail-open to 0 (front of queue) when a file can
838
+ // no longer be stat'd.
839
+ const mtimeOf = (m) => {
840
+ try {
841
+ return fs.statSync(m.filePath).mtimeMs;
842
+ }
843
+ catch {
844
+ return 0;
845
+ }
846
+ };
847
+ const mtimeCache = new Map(memories.map((m) => [m.filePath, mtimeOf(m)]));
848
+ memories = [...memories].sort((a, b) => (mtimeCache.get(a.filePath) ?? 0) - (mtimeCache.get(b.filePath) ?? 0));
849
+ warnings.push(`Consolidation: pool capped at ${opts.limit} of ${memories.length} memories (limit option, oldest-modified first).`);
850
+ memories = memories.slice(0, opts.limit);
851
+ }
831
852
  // Consolidation always uses the HTTP LLM client directly — never the agent
832
853
  // CLI. The agent CLI is for interactive agent sessions (reflect, propose);
833
854
  // structured JSON generation works better and faster via HTTP.
@@ -1992,10 +2013,23 @@ async function checkPreEmitDedup(opts) {
1992
2013
  * everything changed or the index can't answer (fail-open to preserve merge
1993
2014
  * correctness). `since` is an ISO timestamp.
1994
2015
  */
1995
- export function narrowToIncrementalCandidates(memories, since, warnings) {
2016
+ /**
2017
+ * Parse a human-readable duration string (e.g. "30m", "24h", "7d") to an ISO
2018
+ * timestamp representing `now - duration`. Returns the input unchanged when it
2019
+ * doesn't match the pattern (assumed to already be an ISO timestamp).
2020
+ */
2021
+ function parseSinceToIso(since) {
2022
+ const m = since.match(/^(\d+)(m|h|d)$/);
2023
+ if (!m)
2024
+ return since;
2025
+ const multiplier = { m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
2026
+ return new Date(Date.now() - parseInt(m[1], 10) * multiplier).toISOString();
2027
+ }
2028
+ export function narrowToIncrementalCandidates(memories, since, warnings, neighborsPerChanged = 5) {
2029
+ const sinceIso = parseSinceToIso(since);
1996
2030
  const isChanged = (m) => {
1997
2031
  try {
1998
- return fs.statSync(m.filePath).mtime.toISOString() > since;
2032
+ return fs.statSync(m.filePath).mtime.toISOString() > sinceIso;
1999
2033
  }
2000
2034
  catch {
2001
2035
  return true; // never silently drop a memory we cannot stat
@@ -2006,7 +2040,6 @@ export function narrowToIncrementalCandidates(memories, since, warnings) {
2006
2040
  return [];
2007
2041
  if (changed.length === memories.length)
2008
2042
  return memories;
2009
- const NEIGHBORS_PER_CHANGED = 5;
2010
2043
  const byName = new Map(memories.map((m) => [m.name, m]));
2011
2044
  const keep = new Set(changed.map((m) => m.name));
2012
2045
  let db;
@@ -2016,7 +2049,7 @@ export function narrowToIncrementalCandidates(memories, since, warnings) {
2016
2049
  const id = findEntryIdByRef(db, `memory:${m.name}`);
2017
2050
  if (id === undefined)
2018
2051
  continue;
2019
- for (const hit of getNeighborsByEntryId(db, id, NEIGHBORS_PER_CHANGED + 1)) {
2052
+ for (const hit of getNeighborsByEntryId(db, id, neighborsPerChanged + 1)) {
2020
2053
  if (hit.id === id)
2021
2054
  continue;
2022
2055
  const entry = getEntryById(db, hit.id);
@@ -2037,7 +2070,7 @@ export function narrowToIncrementalCandidates(memories, since, warnings) {
2037
2070
  closeDatabase(db);
2038
2071
  }
2039
2072
  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}).`);
2073
+ warnings.push(`Incremental consolidation: ${changed.length} changed + neighbours → ${candidates.length}/${memories.length} memories considered (since ${since}${sinceIso !== since ? ` = ${sinceIso}` : ""}).`);
2041
2074
  return candidates;
2042
2075
  }
2043
2076
  function loadMemoriesForSource(source, stashDir, warnings) {
@@ -586,7 +586,7 @@ similarLessons) {
586
586
  * @param reason - Human-readable rejection reason.
587
587
  * @param extraMeta - Optional additional metadata for the event.
588
588
  */
589
- function writeQualityRejection(stash, inputRef, lessonRef, content, score, reason, extraMeta = {}) {
589
+ function writeQualityRejection(stash, inputRef, lessonRef, content, score, reason, extraMeta = {}, eligibilitySource) {
590
590
  // D-5 / #388: reviewNeeded flag selects "review_needed" vs "quality_rejected" outcome.
591
591
  const outcome = extraMeta.reviewNeeded ? "review_needed" : "quality_rejected";
592
592
  const rejectDir = path.join(stash, ".akm", "distill-rejected");
@@ -602,6 +602,9 @@ function writeQualityRejection(stash, inputRef, lessonRef, content, score, reaso
602
602
  score,
603
603
  reason,
604
604
  ...extraMeta,
605
+ // Attribution tagging: stamp the eligibility lane so distill_invoked can be
606
+ // sliced by lane downstream. See EligibilitySource.
607
+ ...(eligibilitySource ? { eligibilitySource } : {}),
605
608
  },
606
609
  });
607
610
  return {
@@ -629,6 +632,12 @@ export async function akmDistill(options) {
629
632
  // Validate the ref shape up front so a typo never reaches the LLM.
630
633
  const parsedInputRef = parseAssetRef(inputRef);
631
634
  const targetKind = options.proposalKind ?? "lesson";
635
+ // Attribution tagging: spread into every distill_invoked event's metadata so
636
+ // the lane that selected this asset is recorded uniformly across all outcome
637
+ // branches. Empty object when no lane was supplied (direct `akm distill`).
638
+ const eligMeta = options.eligibilitySource
639
+ ? { eligibilitySource: options.eligibilitySource }
640
+ : {};
632
641
  // Recursive-distillation guard. Distill produces *lessons* from non-lesson
633
642
  // sources (memory, skill, knowledge, etc.). Calling distill on an existing
634
643
  // lesson would derive `lesson:lesson-<name>-lesson-lesson` (double `-lesson`
@@ -650,6 +659,7 @@ export async function akmDistill(options) {
650
659
  lessonRef: skippedRef,
651
660
  message: "distill refuses lesson inputs — lessons are the distilled form, not a source",
652
661
  skipReason: "recursive_lesson_input",
662
+ ...eligMeta,
653
663
  },
654
664
  });
655
665
  return {
@@ -766,6 +776,7 @@ export async function akmDistill(options) {
766
776
  outcome: "skipped",
767
777
  lessonRef: promotion.knowledgeRef,
768
778
  message: "D-1: LLM resolved destination conflict as NOOP — existing content kept",
779
+ ...eligMeta,
769
780
  },
770
781
  });
771
782
  return {
@@ -814,9 +825,9 @@ export async function akmDistill(options) {
814
825
  if (!judgeResult.pass) {
815
826
  if (judgeResult.reviewNeeded) {
816
827
  // Uncertainty band (2.5–3.5): queue as review_needed instead of rejecting.
817
- return writeQualityRejection(stash, inputRef, promotion.knowledgeRef, resolvedPromotionContent, judgeResult.score, judgeResult.reason, { reviewNeeded: true });
828
+ return writeQualityRejection(stash, inputRef, promotion.knowledgeRef, resolvedPromotionContent, judgeResult.score, judgeResult.reason, { reviewNeeded: true }, options.eligibilitySource);
818
829
  }
819
- return writeQualityRejection(stash, inputRef, promotion.knowledgeRef, resolvedPromotionContent, judgeResult.score, judgeResult.reason);
830
+ return writeQualityRejection(stash, inputRef, promotion.knowledgeRef, resolvedPromotionContent, judgeResult.score, judgeResult.reason, {}, options.eligibilitySource);
820
831
  }
821
832
  // Normalize 1-5 judge score to [0, 1]. Score of -1 means pass-through
822
833
  // (no LLM / timeout / parse failure) — leave confidence undefined so
@@ -834,6 +845,8 @@ export async function akmDistill(options) {
834
845
  ...(Object.keys(knowledgeParsed.data).length > 0 ? { frontmatter: knowledgeParsed.data } : {}),
835
846
  },
836
847
  ...(knowledgeJudgeConfidence !== undefined ? { confidence: knowledgeJudgeConfidence } : {}),
848
+ // Attribution tagging: persist the eligibility lane on the proposal.
849
+ ...(options.eligibilitySource ? { eligibilitySource: options.eligibilitySource } : {}),
837
850
  }, options.ctx);
838
851
  if (isProposalSkipped(proposalResult)) {
839
852
  appendEvent({
@@ -844,6 +857,7 @@ export async function akmDistill(options) {
844
857
  lessonRef: promotion.knowledgeRef,
845
858
  message: proposalResult.message,
846
859
  skipReason: proposalResult.reason,
860
+ ...eligMeta,
847
861
  },
848
862
  });
849
863
  return {
@@ -867,6 +881,7 @@ export async function akmDistill(options) {
867
881
  proposalId: proposal.id,
868
882
  ...(options.sourceRun !== undefined ? { sourceRun: options.sourceRun } : {}),
869
883
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
884
+ ...eligMeta,
870
885
  },
871
886
  });
872
887
  return {
@@ -979,6 +994,7 @@ export async function akmDistill(options) {
979
994
  lessonRef: effectiveLessonRef,
980
995
  proposalKind: effectiveProposalKind,
981
996
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
997
+ ...eligMeta,
982
998
  },
983
999
  });
984
1000
  return {
@@ -1203,6 +1219,7 @@ export async function akmDistill(options) {
1203
1219
  proposalKind: effectiveProposalKind,
1204
1220
  findingKinds: findings.map((f) => f.kind),
1205
1221
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
1222
+ ...eligMeta,
1206
1223
  },
1207
1224
  });
1208
1225
  const message = findings.map((f) => f.message).join("\n");
@@ -1224,9 +1241,9 @@ export async function akmDistill(options) {
1224
1241
  return writeQualityRejection(stash, inputRef, effectiveLessonRef, content, judgeResult.score, judgeResult.reason, {
1225
1242
  reviewNeeded: true,
1226
1243
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {}),
1227
- });
1244
+ }, options.eligibilitySource);
1228
1245
  }
1229
- return writeQualityRejection(stash, inputRef, effectiveLessonRef, content, judgeResult.score, judgeResult.reason, exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {});
1246
+ return writeQualityRejection(stash, inputRef, effectiveLessonRef, content, judgeResult.score, judgeResult.reason, exclusionSet.size > 0 ? { filteredFeedbackCount, feedbackFullyFiltered } : {}, options.eligibilitySource);
1230
1247
  }
1231
1248
  // Normalize 1-5 judge score to [0, 1]. Score of -1 means pass-through
1232
1249
  // (no LLM / timeout / parse failure) — leave confidence undefined so
@@ -1256,6 +1273,8 @@ export async function akmDistill(options) {
1256
1273
  frontmatter: frontmatterWithSources,
1257
1274
  },
1258
1275
  ...(lessonJudgeConfidence !== undefined ? { confidence: lessonJudgeConfidence } : {}),
1276
+ // Attribution tagging: persist the eligibility lane on the proposal.
1277
+ ...(options.eligibilitySource ? { eligibilitySource: options.eligibilitySource } : {}),
1259
1278
  }, options.ctx);
1260
1279
  if (isProposalSkipped(proposalResult2)) {
1261
1280
  appendEvent({
@@ -1266,6 +1285,7 @@ export async function akmDistill(options) {
1266
1285
  lessonRef: effectiveLessonRef,
1267
1286
  message: proposalResult2.message,
1268
1287
  skipReason: proposalResult2.reason,
1288
+ ...eligMeta,
1269
1289
  },
1270
1290
  });
1271
1291
  return {
@@ -1290,6 +1310,7 @@ export async function akmDistill(options) {
1290
1310
  ...(options.sourceRun !== undefined ? { sourceRun: options.sourceRun } : {}),
1291
1311
  ...(exclusionSet.size > 0 ? { filteredFeedbackCount } : {}),
1292
1312
  ...(descriptionSwapped > 0 ? { descriptionSwapped } : {}),
1313
+ ...eligMeta,
1293
1314
  },
1294
1315
  });
1295
1316
  return {
@@ -55,7 +55,7 @@ export const EXTRACT_JSON_SCHEMA = {
55
55
  type: "string",
56
56
  minLength: 20,
57
57
  maxLength: 400,
58
- description: "One-sentence summary of the candidate. Must be a complete sentence; do not end mid-clause.",
58
+ description: "One-sentence summary of the candidate. Must be a complete sentence in active voice. Do NOT start with 'When', 'If', 'How', 'Use', or 'Avoid'. Do NOT end with ':', ';', or ','. Do NOT use heading-fragment text ('Summary', 'Overview', 'Key finding:'). Minimum 20 characters, maximum 400 characters.",
59
59
  },
60
60
  when_to_use: {
61
61
  type: "string",