akm-cli 0.9.0-beta.0 → 0.9.0-beta.10

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 (58) hide show
  1. package/CHANGELOG.md +511 -0
  2. package/dist/assets/profiles/quick.json +2 -1
  3. package/dist/assets/templates/html/default.html +78 -0
  4. package/dist/assets/templates/html/health.html +732 -0
  5. package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
  6. package/dist/cli/shared.js +21 -5
  7. package/dist/cli.js +47 -5
  8. package/dist/commands/config-cli.js +0 -10
  9. package/dist/commands/feedback-cli.js +42 -37
  10. package/dist/commands/graph/graph.js +75 -71
  11. package/dist/commands/health/checks.js +48 -0
  12. package/dist/commands/health/html-report.js +666 -0
  13. package/dist/commands/health.js +186 -13
  14. package/dist/commands/improve/consolidate.js +39 -6
  15. package/dist/commands/improve/distill.js +26 -5
  16. package/dist/commands/improve/extract-prompt.js +1 -1
  17. package/dist/commands/improve/extract.js +52 -8
  18. package/dist/commands/improve/improve-auto-accept.js +33 -1
  19. package/dist/commands/improve/improve-cli.js +7 -0
  20. package/dist/commands/improve/improve-profiles.js +4 -0
  21. package/dist/commands/improve/improve.js +877 -433
  22. package/dist/commands/improve/proactive-maintenance.js +113 -0
  23. package/dist/commands/improve/reflect-noise.js +0 -0
  24. package/dist/commands/improve/reflect.js +31 -0
  25. package/dist/commands/proposal/drain.js +73 -6
  26. package/dist/commands/proposal/proposal-cli.js +22 -10
  27. package/dist/commands/proposal/proposal.js +17 -1
  28. package/dist/commands/proposal/validators/proposals.js +365 -329
  29. package/dist/commands/read/curate.js +17 -0
  30. package/dist/commands/remember.js +6 -2
  31. package/dist/commands/sources/stash-cli.js +10 -2
  32. package/dist/commands/tasks/tasks.js +32 -8
  33. package/dist/core/config/config-schema.js +30 -0
  34. package/dist/core/file-lock.js +22 -0
  35. package/dist/core/logs-db.js +304 -0
  36. package/dist/core/paths.js +3 -0
  37. package/dist/core/state-db.js +152 -14
  38. package/dist/indexer/db/db.js +99 -13
  39. package/dist/indexer/ensure-index.js +152 -17
  40. package/dist/indexer/index-writer-lock.js +99 -0
  41. package/dist/indexer/indexer.js +114 -111
  42. package/dist/indexer/passes/memory-inference.js +61 -22
  43. package/dist/integrations/harnesses/claude/session-log.js +17 -5
  44. package/dist/llm/client.js +38 -4
  45. package/dist/llm/usage-persist.js +77 -0
  46. package/dist/llm/usage-telemetry.js +103 -0
  47. package/dist/output/context.js +3 -2
  48. package/dist/output/html-render.js +73 -0
  49. package/dist/output/shapes/helpers.js +17 -1
  50. package/dist/output/text/helpers.js +69 -1
  51. package/dist/scripts/migrate-storage.js +153 -25
  52. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +21 -2
  53. package/dist/sources/providers/tar-utils.js +16 -8
  54. package/dist/tasks/backends/cron.js +46 -9
  55. package/dist/tasks/runner.js +99 -16
  56. package/dist/workflows/db.js +4 -0
  57. package/package.json +3 -2
  58. 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
  }
@@ -758,11 +834,19 @@ function computeWallTimeStats(durationsMs, byPhase) {
758
834
  }
759
835
  function buildImproveSkipSummary(events) {
760
836
  const skipReasons = {};
837
+ let skipped = 0;
761
838
  for (const event of events) {
762
839
  const reason = typeof event.metadata?.reason === "string" && event.metadata.reason.trim() ? event.metadata.reason : "unknown";
763
- skipReasons[reason] = (skipReasons[reason] ?? 0) + 1;
840
+ // Aggregated skip events (e.g. `no_new_signal`, `profile_filtered_all_passes`)
841
+ // carry a `count` of the refs they represent in a single row instead of one
842
+ // event per ref. Honor that count so the skip histogram reflects the true
843
+ // number of skipped refs; per-ref events without a count contribute 1.
844
+ const rawCount = event.metadata?.count;
845
+ const count = typeof rawCount === "number" && Number.isFinite(rawCount) && rawCount > 0 ? rawCount : 1;
846
+ skipReasons[reason] = (skipReasons[reason] ?? 0) + count;
847
+ skipped += count;
764
848
  }
765
- return { skipped: events.length, skipReasons };
849
+ return { skipped, skipReasons };
766
850
  }
767
851
  function probeStateDbRoundTrip(stateDbPath) {
768
852
  const before = readEvents({}, { dbPath: stateDbPath }).nextOffset;
@@ -882,14 +966,84 @@ function computeDeltas(first, last) {
882
966
  }
883
967
  return out;
884
968
  }
885
- function buildWindowMetrics(db, stateDbPath, since, until, now = () => Date.now()) {
969
+ /**
970
+ * Partition task_history rows into "should have a log" (non-null log_path) and
971
+ * "log is actually backed". A run counts as backed when logs.db holds rows for
972
+ * its run_id (#579 — the DB is the primary record); rows written before logs.db
973
+ * existed fall back to the transitional on-disk file check. `logsDb` may be
974
+ * undefined when logs.db could not be opened — then only the file check runs.
975
+ */
976
+ function partitionLogBackedRows(taskRows, logsDb) {
977
+ const withLogs = taskRows.filter((row) => row.log_path !== null);
978
+ const loggedRunIds = logsDb
979
+ ? getLoggedRunIds(logsDb, withLogs.map((row) => buildTaskRunId(row.task_id, row.started_at)))
980
+ : new Set();
981
+ const backed = withLogs.filter((row) => loggedRunIds.has(buildTaskRunId(row.task_id, row.started_at)) ||
982
+ (row.log_path !== null && fs.existsSync(row.log_path)));
983
+ return { withLogs, backed };
984
+ }
985
+ /** Stage key used for `llm_usage` events recorded outside any stage scope. */
986
+ const UNATTRIBUTED_STAGE = "unattributed";
987
+ function emptyLlmUsageStageAggregate() {
988
+ return {
989
+ calls: 0,
990
+ totalDurationMs: 0,
991
+ promptTokens: 0,
992
+ completionTokens: 0,
993
+ totalTokens: 0,
994
+ reasoningTokens: 0,
995
+ };
996
+ }
997
+ function emptyLlmUsageAggregate() {
998
+ return { ...emptyLlmUsageStageAggregate(), byStage: {} };
999
+ }
1000
+ /**
1001
+ * Aggregate `llm_usage` events (#576) into a window total plus a per-stage
1002
+ * breakdown of call count, wall-time, and token usage. Token fields absent from
1003
+ * a best-effort record contribute 0. Calls with no `stage` land under
1004
+ * {@link UNATTRIBUTED_STAGE}.
1005
+ */
1006
+ function summarizeLlmUsage(events) {
1007
+ const aggregate = emptyLlmUsageAggregate();
1008
+ for (const event of events) {
1009
+ const meta = event.metadata ?? {};
1010
+ const stageKey = typeof meta.stage === "string" && meta.stage ? meta.stage : UNATTRIBUTED_STAGE;
1011
+ let stage = aggregate.byStage[stageKey];
1012
+ if (!stage) {
1013
+ stage = emptyLlmUsageStageAggregate();
1014
+ aggregate.byStage[stageKey] = stage;
1015
+ }
1016
+ const durationMs = toFiniteNumber(meta.durationMs);
1017
+ const promptTokens = toFiniteNumber(meta.promptTokens);
1018
+ const completionTokens = toFiniteNumber(meta.completionTokens);
1019
+ const totalTokens = toFiniteNumber(meta.totalTokens);
1020
+ const reasoningTokens = toFiniteNumber(meta.reasoningTokens);
1021
+ for (const target of [aggregate, stage]) {
1022
+ target.calls += 1;
1023
+ target.totalDurationMs += durationMs;
1024
+ target.promptTokens += promptTokens;
1025
+ target.completionTokens += completionTokens;
1026
+ target.totalTokens += totalTokens;
1027
+ target.reasoningTokens += reasoningTokens;
1028
+ }
1029
+ }
1030
+ return aggregate;
1031
+ }
1032
+ function readLlmUsageAggregate(stateDbPath, since, until) {
1033
+ const events = readEvents({ since, type: LLM_USAGE_EVENT }, { dbPath: stateDbPath }).events.filter((event) => {
1034
+ if (until === undefined)
1035
+ return true;
1036
+ return new Date(event.ts ?? since).getTime() < new Date(until).getTime();
1037
+ });
1038
+ return summarizeLlmUsage(events);
1039
+ }
1040
+ function buildWindowMetrics(db, stateDbPath, since, until, now = () => Date.now(), logsDb) {
886
1041
  const taskRows = queryTaskHistory(db, { since }).filter((row) => {
887
1042
  const startMs = new Date(row.started_at).getTime();
888
1043
  const untilMs = new Date(until).getTime();
889
1044
  return !Number.isFinite(untilMs) || startMs < untilMs;
890
1045
  });
891
- const taskRowsWithLogs = taskRows.filter((row) => row.log_path !== null);
892
- const existingLogRows = taskRowsWithLogs.filter((row) => row.log_path && fs.existsSync(row.log_path));
1046
+ const { withLogs: taskRowsWithLogs, backed: existingLogRows } = partitionLogBackedRows(taskRows, logsDb);
893
1047
  const failedTaskRows = taskRows.filter((row) => row.status === "failed");
894
1048
  const activeRows = taskRows.filter((row) => row.status === "active");
895
1049
  const stuckActiveRuns = activeRows.filter((row) => now() - new Date(row.started_at).getTime() > ACTIVE_RUN_WARN_MS).length;
@@ -923,6 +1077,7 @@ function buildWindowMetrics(db, stateDbPath, since, until, now = () => Date.now(
923
1077
  stuckActiveRuns,
924
1078
  logBackingRate: roundRate(logBackingRate),
925
1079
  probeRoundTripMs: null,
1080
+ llmUsage: readLlmUsageAggregate(stateDbPath, since, until),
926
1081
  };
927
1082
  return { improve: improveSummary, metrics, runs: runCount };
928
1083
  }
@@ -961,6 +1116,16 @@ export function akmHealth(options = {}) {
961
1116
  catch (error) {
962
1117
  throw new ConfigError(`Unable to open state.db: ${error instanceof Error ? error.message : String(error)}`, "INVALID_CONFIG_FILE");
963
1118
  }
1119
+ // logs.db backs the log-backing metric (#579). Best-effort: when it cannot
1120
+ // be opened, partitionLogBackedRows falls back to the on-disk file check, so
1121
+ // health never hard-fails on a missing/locked logs database.
1122
+ let logsDb;
1123
+ try {
1124
+ logsDb = openLogsDatabase(options.logsDbPath);
1125
+ }
1126
+ catch {
1127
+ logsDb = undefined;
1128
+ }
964
1129
  try {
965
1130
  const tables = listExistingTableNames(db, ["events", "task_history", "proposals", "schema_migrations"]);
966
1131
  const tableNames = tables.map((row) => row.name).sort();
@@ -968,8 +1133,7 @@ export function akmHealth(options = {}) {
968
1133
  const missingTables = requiredTables.filter((name) => !tableNames.includes(name));
969
1134
  const probe = probeStateDbRoundTrip(stateDbPath);
970
1135
  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));
1136
+ const { withLogs: taskRowsWithLogs, backed: existingLogRows } = partitionLogBackedRows(taskRows, logsDb);
973
1137
  const failedTaskRows = taskRows.filter((row) => row.status === "failed");
974
1138
  const activeRows = taskRows.filter((row) => row.status === "active");
975
1139
  const stuckActiveRuns = activeRows.filter((row) => now() - new Date(row.started_at).getTime() > ACTIVE_RUN_WARN_MS).length;
@@ -1041,6 +1205,7 @@ export function akmHealth(options = {}) {
1041
1205
  stuckActiveRuns,
1042
1206
  logBackingRate: roundRate(logBackingRate),
1043
1207
  probeRoundTripMs: probe.durationMs,
1208
+ llmUsage: readLlmUsageAggregate(stateDbPath, since),
1044
1209
  };
1045
1210
  const hardFailure = hardChecks.some((check) => check.status === "fail");
1046
1211
  const deterministicWarnings = [...hardChecks, ...advisories].some((check) => check.status === "warn" && check.kind === "deterministic");
@@ -1062,7 +1227,7 @@ export function akmHealth(options = {}) {
1062
1227
  windowResults = windowSpecs.map((spec) => {
1063
1228
  const winSince = parseHealthSince(spec.since);
1064
1229
  const winUntil = spec.until ? parseHealthSince(spec.until) : new Date(now()).toISOString();
1065
- const bundle = buildWindowMetrics(db, stateDbPath, winSince, winUntil, now);
1230
+ const bundle = buildWindowMetrics(db, stateDbPath, winSince, winUntil, now, logsDb);
1066
1231
  return {
1067
1232
  name: spec.name,
1068
1233
  since: winSince,
@@ -1112,6 +1277,14 @@ export function akmHealth(options = {}) {
1112
1277
  }
1113
1278
  finally {
1114
1279
  db.close();
1280
+ if (logsDb) {
1281
+ try {
1282
+ logsDb.close();
1283
+ }
1284
+ catch {
1285
+ // best-effort
1286
+ }
1287
+ }
1115
1288
  }
1116
1289
  }
1117
1290
  // ── 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",