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.
- package/CHANGELOG.md +511 -0
- package/dist/assets/profiles/quick.json +2 -1
- package/dist/assets/templates/html/default.html +78 -0
- package/dist/assets/templates/html/health.html +732 -0
- package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
- package/dist/cli/shared.js +21 -5
- package/dist/cli.js +47 -5
- package/dist/commands/config-cli.js +0 -10
- package/dist/commands/feedback-cli.js +42 -37
- package/dist/commands/graph/graph.js +75 -71
- package/dist/commands/health/checks.js +48 -0
- package/dist/commands/health/html-report.js +666 -0
- package/dist/commands/health.js +186 -13
- package/dist/commands/improve/consolidate.js +39 -6
- package/dist/commands/improve/distill.js +26 -5
- package/dist/commands/improve/extract-prompt.js +1 -1
- package/dist/commands/improve/extract.js +52 -8
- package/dist/commands/improve/improve-auto-accept.js +33 -1
- package/dist/commands/improve/improve-cli.js +7 -0
- package/dist/commands/improve/improve-profiles.js +4 -0
- package/dist/commands/improve/improve.js +877 -433
- package/dist/commands/improve/proactive-maintenance.js +113 -0
- package/dist/commands/improve/reflect-noise.js +0 -0
- package/dist/commands/improve/reflect.js +31 -0
- package/dist/commands/proposal/drain.js +73 -6
- package/dist/commands/proposal/proposal-cli.js +22 -10
- package/dist/commands/proposal/proposal.js +17 -1
- package/dist/commands/proposal/validators/proposals.js +365 -329
- package/dist/commands/read/curate.js +17 -0
- package/dist/commands/remember.js +6 -2
- package/dist/commands/sources/stash-cli.js +10 -2
- package/dist/commands/tasks/tasks.js +32 -8
- package/dist/core/config/config-schema.js +30 -0
- package/dist/core/file-lock.js +22 -0
- package/dist/core/logs-db.js +304 -0
- package/dist/core/paths.js +3 -0
- package/dist/core/state-db.js +152 -14
- package/dist/indexer/db/db.js +99 -13
- package/dist/indexer/ensure-index.js +152 -17
- package/dist/indexer/index-writer-lock.js +99 -0
- package/dist/indexer/indexer.js +114 -111
- package/dist/indexer/passes/memory-inference.js +61 -22
- package/dist/integrations/harnesses/claude/session-log.js +17 -5
- package/dist/llm/client.js +38 -4
- package/dist/llm/usage-persist.js +77 -0
- package/dist/llm/usage-telemetry.js +103 -0
- package/dist/output/context.js +3 -2
- package/dist/output/html-render.js +73 -0
- package/dist/output/shapes/helpers.js +17 -1
- package/dist/output/text/helpers.js +69 -1
- package/dist/scripts/migrate-storage.js +153 -25
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +21 -2
- package/dist/sources/providers/tar-utils.js +16 -8
- package/dist/tasks/backends/cron.js +46 -9
- package/dist/tasks/runner.js +99 -16
- package/dist/workflows/db.js +4 -0
- package/package.json +3 -2
- package/dist/commands/config-edit.js +0 -344
package/dist/commands/health.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
508
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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() >
|
|
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,
|
|
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
|
|
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",
|