akm-cli 0.9.0-beta.2 → 0.9.0-beta.4
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 +248 -0
- package/dist/assets/templates/html/default.html +78 -0
- package/dist/assets/templates/html/health.html +560 -0
- package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
- package/dist/cli/shared.js +21 -5
- package/dist/cli.js +36 -5
- package/dist/commands/health/html-report.js +448 -0
- package/dist/commands/health.js +97 -6
- package/dist/commands/improve/consolidate.js +15 -2
- package/dist/commands/improve/extract.js +38 -2
- package/dist/commands/improve/improve-auto-accept.js +27 -1
- package/dist/commands/improve/improve.js +167 -53
- package/dist/commands/improve/reflect-noise.js +0 -0
- package/dist/commands/improve/reflect.js +25 -0
- package/dist/commands/proposal/drain.js +73 -6
- package/dist/commands/proposal/proposal-cli.js +22 -10
- package/dist/commands/proposal/proposal.js +12 -1
- package/dist/commands/proposal/validators/proposals.js +361 -338
- package/dist/commands/remember.js +6 -2
- package/dist/core/config/config-schema.js +5 -0
- package/dist/core/logs-db.js +304 -0
- package/dist/core/state-db.js +107 -14
- package/dist/indexer/db/db.js +2 -2
- package/dist/indexer/passes/memory-inference.js +61 -22
- package/dist/integrations/harnesses/claude/session-log.js +16 -4
- package/dist/llm/client.js +15 -0
- 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 +65 -14
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +14 -2
- package/dist/tasks/runner.js +99 -16
- package/dist/workflows/db.js +4 -0
- package/package.json +2 -1
package/dist/cli/shared.js
CHANGED
|
@@ -12,6 +12,7 @@ import { stringify as yamlStringify } from "yaml";
|
|
|
12
12
|
import { assertNever } from "../core/assert.js";
|
|
13
13
|
import { AkmError } from "../core/errors.js";
|
|
14
14
|
import { getOutputMode } from "../output/context.js";
|
|
15
|
+
import { DEFAULT_TEMPLATE, deliverRendered, escapeHtml, renderHtml, resolveTemplatePath } from "../output/html-render.js";
|
|
15
16
|
import { shapeForCommand } from "../output/shapes.js";
|
|
16
17
|
import { formatPlain, outputJsonl } from "../output/text.js";
|
|
17
18
|
// ── Exit codes ───────────────────────────────────────────────────────────────
|
|
@@ -111,7 +112,10 @@ export function defineJsonCommand(def) {
|
|
|
111
112
|
});
|
|
112
113
|
}
|
|
113
114
|
/**
|
|
114
|
-
* Render a command result according to the active output mode
|
|
115
|
+
* Render a command result according to the active output mode
|
|
116
|
+
* (json/jsonl/yaml/text/md/html). When `--output <path>` is set, the rendered
|
|
117
|
+
* document is written to that file instead of stdout (jsonl excepted — it is
|
|
118
|
+
* a line-streaming protocol and always goes to stdout).
|
|
115
119
|
*/
|
|
116
120
|
export function output(command, result) {
|
|
117
121
|
const mode = getOutputMode();
|
|
@@ -122,14 +126,14 @@ export function output(command, result) {
|
|
|
122
126
|
}
|
|
123
127
|
switch (mode.format) {
|
|
124
128
|
case "json":
|
|
125
|
-
|
|
129
|
+
deliverRendered(JSON.stringify(shaped, null, 2), mode.outputPath);
|
|
126
130
|
return;
|
|
127
131
|
case "yaml":
|
|
128
|
-
|
|
132
|
+
deliverRendered(yamlStringify(shaped), mode.outputPath);
|
|
129
133
|
return;
|
|
130
134
|
case "text": {
|
|
131
135
|
const plain = formatPlain(command, shaped, mode.detail);
|
|
132
|
-
|
|
136
|
+
deliverRendered(plain ?? JSON.stringify(shaped, null, 2), mode.outputPath);
|
|
133
137
|
return;
|
|
134
138
|
}
|
|
135
139
|
case "md":
|
|
@@ -137,8 +141,20 @@ export function output(command, result) {
|
|
|
137
141
|
// per-run / window-compare table renderings. Commands that don't
|
|
138
142
|
// implement an md renderer fall back to the JSON envelope so
|
|
139
143
|
// pipelines never get an empty stdout.
|
|
140
|
-
|
|
144
|
+
deliverRendered(JSON.stringify(shaped, null, 2), mode.outputPath);
|
|
141
145
|
return;
|
|
146
|
+
case "html": {
|
|
147
|
+
// Generic fallback: render the JSON envelope inside the dark-mode
|
|
148
|
+
// default template. Commands with a bespoke HTML template (`akm health`)
|
|
149
|
+
// intercept before reaching output(), same as the `md` intercept.
|
|
150
|
+
const html = renderHtml(resolveTemplatePath(DEFAULT_TEMPLATE), {
|
|
151
|
+
"%%COMMAND%%": escapeHtml(command),
|
|
152
|
+
"%%CONTENT_JSON%%": escapeHtml(JSON.stringify(shaped, null, 2)),
|
|
153
|
+
"%%GENERATED_AT%%": new Date().toISOString(),
|
|
154
|
+
});
|
|
155
|
+
deliverRendered(html, mode.outputPath);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
142
158
|
}
|
|
143
159
|
}
|
|
144
160
|
/**
|
package/dist/cli.js
CHANGED
|
@@ -88,6 +88,7 @@ import { getCacheDir, getConfigPath, getDbPath } from "./core/paths.js";
|
|
|
88
88
|
import { plainize } from "./core/tty.js";
|
|
89
89
|
import { info, isQuiet, setQuiet, setVerbose, warn } from "./core/warn.js";
|
|
90
90
|
import { getHyphenatedBoolean, getOutputMode, initOutputMode, parseFlagValue } from "./output/context.js";
|
|
91
|
+
import { deliverRendered, renderHtml, resolveTemplatePath } from "./output/html-render.js";
|
|
91
92
|
import { pkgVersion } from "./version.js";
|
|
92
93
|
function applyEarlyStderrFlags(argv) {
|
|
93
94
|
if (argv.includes("--quiet") || argv.includes("-q")) {
|
|
@@ -297,16 +298,43 @@ const healthCommand = defineCommand({
|
|
|
297
298
|
type: "string",
|
|
298
299
|
description: "Explicit comparison window 'name=...,since=ISO,until=ISO' (repeatable, up to 4; mutually exclusive with --window-compare)",
|
|
299
300
|
},
|
|
301
|
+
compare: {
|
|
302
|
+
type: "string",
|
|
303
|
+
description: "Comparison window for the --format html report's trend deltas (default: 24h)",
|
|
304
|
+
},
|
|
300
305
|
},
|
|
301
306
|
async run({ args }) {
|
|
302
307
|
let resultStatus;
|
|
303
|
-
await runWithJsonErrors(() => {
|
|
308
|
+
await runWithJsonErrors(async () => {
|
|
304
309
|
// citty only surfaces the last value of a repeated flag, so read --windows
|
|
305
310
|
// directly from argv to support multi-window comparison.
|
|
306
311
|
const rawWindows = parseAllFlagValues("--windows");
|
|
307
312
|
const windows = rawWindows.length > 0 ? rawWindows.map((raw) => parseWindowSpec(raw)) : undefined;
|
|
308
313
|
const groupBy = args["group-by"];
|
|
309
314
|
const windowCompareRaw = args["window-compare"];
|
|
315
|
+
const mode = getOutputMode();
|
|
316
|
+
// `--format html` is health-specific: render the full HTML health
|
|
317
|
+
// report (charts, KPI cards, advisories) from the bespoke template.
|
|
318
|
+
// Mirrors the `md` intercept below. Two reads, exactly like the
|
|
319
|
+
// retired akm-health-report skill: the canonical per-run window plus a
|
|
320
|
+
// window-compare read for the trend deltas (defaults to 24h,
|
|
321
|
+
// overridable via --compare).
|
|
322
|
+
if (mode.format === "html") {
|
|
323
|
+
const compare = args.compare ?? windowCompareRaw ?? "24h";
|
|
324
|
+
const result = akmHealth({ since: args.since, groupBy: "run" });
|
|
325
|
+
resultStatus = result.status;
|
|
326
|
+
const deltas = akmHealth({ since: args.since, windowCompare: compare }).deltas;
|
|
327
|
+
const { buildHealthHtmlReplacements } = await import("./commands/health/html-report.js");
|
|
328
|
+
const { listPendingProposals } = await import("./commands/proposal/proposal.js");
|
|
329
|
+
const replacements = buildHealthHtmlReplacements(result, {
|
|
330
|
+
window: args.since ?? "24h",
|
|
331
|
+
compare,
|
|
332
|
+
proposals: listPendingProposals(),
|
|
333
|
+
deltas,
|
|
334
|
+
});
|
|
335
|
+
deliverRendered(renderHtml(resolveTemplatePath("health"), replacements), mode.outputPath);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
310
338
|
const result = akmHealth({
|
|
311
339
|
since: args.since,
|
|
312
340
|
groupBy: groupBy,
|
|
@@ -317,13 +345,12 @@ const healthCommand = defineCommand({
|
|
|
317
345
|
// `--format md` is health-specific: render a TSV-shaped per-run or
|
|
318
346
|
// window-compare table to stdout instead of going through the JSON
|
|
319
347
|
// envelope. Other modes fall through to the standard output() path.
|
|
320
|
-
const mode = getOutputMode();
|
|
321
348
|
if (mode.format === "md") {
|
|
322
349
|
if (result.windows && result.windows.length > 0) {
|
|
323
|
-
|
|
350
|
+
deliverRendered(renderWindowCompareMd(result.windows, result.deltas), mode.outputPath);
|
|
324
351
|
}
|
|
325
352
|
else if (result.runs) {
|
|
326
|
-
|
|
353
|
+
deliverRendered(renderRunsDetailMd(result.runs), mode.outputPath);
|
|
327
354
|
}
|
|
328
355
|
else {
|
|
329
356
|
output("health", result);
|
|
@@ -419,7 +446,11 @@ export const main = defineCommand({
|
|
|
419
446
|
" 78 config error",
|
|
420
447
|
},
|
|
421
448
|
args: {
|
|
422
|
-
format: { type: "string", description: "Output format (json|jsonl|text|yaml)", default: "json" },
|
|
449
|
+
format: { type: "string", description: "Output format (json|jsonl|text|yaml|md|html)", default: "json" },
|
|
450
|
+
output: {
|
|
451
|
+
type: "string",
|
|
452
|
+
description: "Write rendered output to a file instead of stdout (all formats except jsonl)",
|
|
453
|
+
},
|
|
423
454
|
detail: {
|
|
424
455
|
type: "string",
|
|
425
456
|
description: "Detail level (verbosity): brief|normal|full. Default: brief.",
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* `akm health --format html` — token builder for the full HTML health report
|
|
6
|
+
* (#582). Ports the external akm-health-report skill's collect.py + render.py
|
|
7
|
+
* to TypeScript so the report is generated in-process (no python, no
|
|
8
|
+
* shell-out). The template (`src/assets/templates/html/health.html`) is a
|
|
9
|
+
* verbatim copy of the skill's report.html; this module computes the 17
|
|
10
|
+
* `%%TOKEN%%` replacements it consumes.
|
|
11
|
+
*
|
|
12
|
+
* Determinism: nothing here depends on Date.now()/Math.random(). Runs are
|
|
13
|
+
* sorted by startedAt; `%%GENERATED_AT%%` derives from the latest run (or the
|
|
14
|
+
* window anchor), so output is byte-identical for identical inputs.
|
|
15
|
+
*/
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { escapeHtml } from "../../output/html-render.js";
|
|
19
|
+
import { getDirname } from "../../runtime.js";
|
|
20
|
+
const ECHARTS_CDN = "https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js";
|
|
21
|
+
const ECHARTS_VENDOR_PATH = path.join(getDirname(import.meta.url), "../../assets/templates/html/vendor/echarts.min.js");
|
|
22
|
+
// ── Small formatters (ports of render.py helpers) ───────────────────────────
|
|
23
|
+
const esc = escapeHtml;
|
|
24
|
+
function num(value) {
|
|
25
|
+
return Math.round(value).toLocaleString("en-US");
|
|
26
|
+
}
|
|
27
|
+
function fmtMs(ms) {
|
|
28
|
+
return ms ? `${(ms / 60000).toFixed(1)}m` : "—";
|
|
29
|
+
}
|
|
30
|
+
function pct(rate, digits) {
|
|
31
|
+
return `${(rate * 100).toFixed(digits)}%`;
|
|
32
|
+
}
|
|
33
|
+
function trendClass(direction) {
|
|
34
|
+
return direction === "up" ? "trend-up" : direction === "down" ? "trend-down" : "trend-flat";
|
|
35
|
+
}
|
|
36
|
+
function trendLabel(direction) {
|
|
37
|
+
return direction === "up" ? "▲ up" : direction === "down" ? "▼ watch" : "— flat";
|
|
38
|
+
}
|
|
39
|
+
/** pctChange may be a number or an "+inf"/"-inf" sentinel (prior window was 0). */
|
|
40
|
+
function coercePct(raw) {
|
|
41
|
+
if (typeof raw === "number")
|
|
42
|
+
return raw;
|
|
43
|
+
if (typeof raw !== "string")
|
|
44
|
+
return undefined;
|
|
45
|
+
const s = raw.trim().toLowerCase();
|
|
46
|
+
if (s === "+inf" || s === "inf" || s === "infinity" || s === "+infinity")
|
|
47
|
+
return 1e9;
|
|
48
|
+
if (s === "-inf" || s === "-infinity")
|
|
49
|
+
return -1e9;
|
|
50
|
+
const parsed = Number.parseFloat(s.replace(/%$/, ""));
|
|
51
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
52
|
+
}
|
|
53
|
+
function reshapeRun(r) {
|
|
54
|
+
const cons = r.consolidation;
|
|
55
|
+
const mi = r.memoryInference;
|
|
56
|
+
const ge = r.graphExtraction;
|
|
57
|
+
const wall = r.wallTimeMs || 0;
|
|
58
|
+
const consMs = cons.durationMs || 0;
|
|
59
|
+
const miMs = mi.durationMs || 0;
|
|
60
|
+
const geMs = ge.durationMs || 0;
|
|
61
|
+
return {
|
|
62
|
+
id: r.id,
|
|
63
|
+
startedAt: r.startedAt,
|
|
64
|
+
completedAt: r.completedAt,
|
|
65
|
+
wallTimeMs: wall,
|
|
66
|
+
ok: r.ok,
|
|
67
|
+
mode: r.scope.mode,
|
|
68
|
+
consDurationMs: consMs,
|
|
69
|
+
miDurationMs: miMs,
|
|
70
|
+
geDurationMs: geMs,
|
|
71
|
+
otherMs: Math.max(0, wall - consMs - miMs - geMs),
|
|
72
|
+
consRan: cons.ran,
|
|
73
|
+
promoted: cons.promoted,
|
|
74
|
+
merged: cons.merged,
|
|
75
|
+
deleted: cons.deleted,
|
|
76
|
+
contradicted: cons.contradicted,
|
|
77
|
+
judgedNoAction: cons.judgedNoAction,
|
|
78
|
+
processed: cons.processed,
|
|
79
|
+
failedChunks: cons.failedChunks,
|
|
80
|
+
totalChunks: cons.totalChunks,
|
|
81
|
+
miWritten: mi.written || mi.writes || 0,
|
|
82
|
+
miConsidered: mi.considered,
|
|
83
|
+
miYieldRate: mi.yieldRate,
|
|
84
|
+
miCacheHits: mi.cacheHits,
|
|
85
|
+
geEntities: ge.entities,
|
|
86
|
+
geRelations: ge.relations,
|
|
87
|
+
geCacheHitRate: ge.cacheHitRate,
|
|
88
|
+
geFailures: ge.failures,
|
|
89
|
+
distillSkipped: r.actions.distill.skipped,
|
|
90
|
+
distillQueued: r.actions.distill.queued,
|
|
91
|
+
distillLlmFailed: r.actions.distill.llmFailed,
|
|
92
|
+
distillByReason: r.actions.distill.skippedByReason,
|
|
93
|
+
reflectOk: r.actions.reflect.ok,
|
|
94
|
+
reflectFailed: r.actions.reflect.failed,
|
|
95
|
+
derived: r.memorySummary.derived,
|
|
96
|
+
eligible: r.memorySummary.eligible,
|
|
97
|
+
lintFlagged: r.lintFlagged,
|
|
98
|
+
lintFixed: r.lintFixed,
|
|
99
|
+
reflectsWithErrorContext: r.reflectsWithErrorContext,
|
|
100
|
+
orphansPurged: r.orphansPurged,
|
|
101
|
+
evalCasesWritten: r.evalCasesWritten,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// ── Trend classification (port of collect.py classify) ──────────────────────
|
|
105
|
+
function classify(deltas, metricKeys, lowerIsBetter = false) {
|
|
106
|
+
const changes = metricKeys
|
|
107
|
+
.map((key) => coercePct(deltas[key]?.pctChange))
|
|
108
|
+
.filter((c) => c !== undefined);
|
|
109
|
+
if (changes.length === 0)
|
|
110
|
+
return "flat";
|
|
111
|
+
const avg = changes.reduce((acc, c) => acc + c, 0) / changes.length;
|
|
112
|
+
if (Math.abs(avg) < 5)
|
|
113
|
+
return "flat";
|
|
114
|
+
const direction = avg > 0 ? "up" : "down";
|
|
115
|
+
if (lowerIsBetter)
|
|
116
|
+
return avg > 0 ? "down" : "up";
|
|
117
|
+
return direction;
|
|
118
|
+
}
|
|
119
|
+
function buildTrend(deltas) {
|
|
120
|
+
const decisionQuality = classify(deltas, ["improve.memoryInference.yieldRate", "improve.consolidation.promoted"]);
|
|
121
|
+
const outputVolume = classify(deltas, [
|
|
122
|
+
"improve.consolidation.promoted",
|
|
123
|
+
"improve.memoryInference.written",
|
|
124
|
+
"improve.sessionExtraction.proposalsCreated",
|
|
125
|
+
]);
|
|
126
|
+
const failures = classify(deltas, ["improve.graphExtraction.failures"], true);
|
|
127
|
+
const latency = classify(deltas, ["improve.wallTime.medianMs", "improve.wallTime.p95Ms"], true);
|
|
128
|
+
const score = [decisionQuality, outputVolume, failures, latency].reduce((acc, d) => acc + (d === "up" ? 1 : d === "down" ? -1 : 0), 0);
|
|
129
|
+
const overall = score >= 1 ? "improving" : score <= -1 ? "degrading" : "mixed";
|
|
130
|
+
return { decisionQuality, outputVolume, failures, latency, overall };
|
|
131
|
+
}
|
|
132
|
+
function deltaPill(deltas, key, lowerIsBetter = false) {
|
|
133
|
+
const raw = deltas[key]?.pctChange;
|
|
134
|
+
if (raw === undefined)
|
|
135
|
+
return '<span class="trend-pill flat">— n/a</span>';
|
|
136
|
+
if (typeof raw === "string") {
|
|
137
|
+
const sign = raw.trim().startsWith("-") ? -1 : 1;
|
|
138
|
+
const good = lowerIsBetter ? sign < 0 : sign > 0;
|
|
139
|
+
return `<span class="trend-pill ${good ? "up" : "down"}">${sign > 0 ? "▲ new" : "▼ gone"}</span>`;
|
|
140
|
+
}
|
|
141
|
+
const good = lowerIsBetter ? raw < 0 : raw > 0;
|
|
142
|
+
const cls = Math.abs(raw) < 1 ? "flat" : good ? "up" : "down";
|
|
143
|
+
const arrow = raw > 0 ? "▲" : raw < 0 ? "▼" : "—";
|
|
144
|
+
const signed = `${raw > 0 ? "+" : ""}${Math.round(raw)}%`;
|
|
145
|
+
return `<span class="trend-pill ${cls}">${arrow} ${signed}</span>`;
|
|
146
|
+
}
|
|
147
|
+
// ── Advisory / watch-item cards ──────────────────────────────────────────────
|
|
148
|
+
function advisoryCard(cls, icon, title, descHtml) {
|
|
149
|
+
return (`<div class="advisory ${cls}"><div class="advisory-icon">${icon}</div>` +
|
|
150
|
+
`<div class="advisory-body"><div class="title">${title}</div>` +
|
|
151
|
+
`<div class="desc">${descHtml}</div></div></div>`);
|
|
152
|
+
}
|
|
153
|
+
function passCard(title, desc) {
|
|
154
|
+
return ('<div class="advisory" style="border-left:3px solid var(--green);">' +
|
|
155
|
+
'<div class="advisory-icon">✅</div><div class="advisory-body">' +
|
|
156
|
+
`<div class="title">${esc(title)}</div>` +
|
|
157
|
+
`<div class="desc">${esc(desc)}</div></div></div>`);
|
|
158
|
+
}
|
|
159
|
+
function readSemSearch(advisories) {
|
|
160
|
+
const check = advisories.find((a) => a.name === "semantic-search-runtime");
|
|
161
|
+
if (!check)
|
|
162
|
+
return { blocked: false, detail: "" };
|
|
163
|
+
const evidence = check.evidence ?? {};
|
|
164
|
+
const status = String(evidence.status ?? "unknown");
|
|
165
|
+
const blocked = check.status !== "pass" || status.toLowerCase().includes("block");
|
|
166
|
+
const entries = typeof evidence.entryCount === "number" ? evidence.entryCount : 0;
|
|
167
|
+
const embeddings = typeof evidence.embeddingCount === "number" ? evidence.embeddingCount : 0;
|
|
168
|
+
return { blocked, detail: `${num(entries)} entries, ${num(embeddings)} embeddings` };
|
|
169
|
+
}
|
|
170
|
+
// ── ECharts delivery ─────────────────────────────────────────────────────────
|
|
171
|
+
function buildEchartsTag(opts) {
|
|
172
|
+
const mode = opts.echarts ?? (process.env.AKM_ECHARTS === "cdn" ? "cdn" : "inline");
|
|
173
|
+
if (mode === "cdn")
|
|
174
|
+
return `<script src="${ECHARTS_CDN}"></script>`;
|
|
175
|
+
const libPath = opts.echartsLibPath ?? ECHARTS_VENDOR_PATH;
|
|
176
|
+
// Guard against an accidental </script> in the minified payload.
|
|
177
|
+
const lib = fs.readFileSync(libPath, "utf8").replaceAll("</script>", "<\\/script>");
|
|
178
|
+
return `<script>\n${lib}\n</script>`;
|
|
179
|
+
}
|
|
180
|
+
// ── Main builder ─────────────────────────────────────────────────────────────
|
|
181
|
+
/**
|
|
182
|
+
* Compute all 17 `%%TOKEN%%` replacements for the health HTML template.
|
|
183
|
+
* There is deliberately NO standalone `%%OVERALL_STATUS%%` token — the
|
|
184
|
+
* overall status is embedded in the pre-rendered badge / exec-summary
|
|
185
|
+
* fragments, matching the skill template.
|
|
186
|
+
*/
|
|
187
|
+
export function buildHealthHtmlReplacements(result, opts) {
|
|
188
|
+
const deltas = opts.deltas ?? {};
|
|
189
|
+
const runs = (result.runs ?? []).map(reshapeRun).sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
190
|
+
const improve = result.improve;
|
|
191
|
+
const proposals = opts.proposals;
|
|
192
|
+
const sem = readSemSearch(result.advisories);
|
|
193
|
+
const trend = buildTrend(deltas);
|
|
194
|
+
// ── Aggregates (collect.py step 6) ─────────────────────────────────────────
|
|
195
|
+
const cons = improve.consolidation;
|
|
196
|
+
const mi = improve.memoryInference;
|
|
197
|
+
const ge = improve.graphExtraction;
|
|
198
|
+
const wallTime = improve.wallTime;
|
|
199
|
+
const totalRuns = runs.length;
|
|
200
|
+
const failedRuns = runs.filter((r) => !r.ok).length;
|
|
201
|
+
const invoked = improve.invoked || totalRuns;
|
|
202
|
+
const completed = improve.completed || totalRuns - failedRuns;
|
|
203
|
+
const miWritten = mi.written || mi.writes || 0;
|
|
204
|
+
const completionRate = invoked ? `${Math.round((100 * completed) / invoked)}%` : "0%";
|
|
205
|
+
const taskFailRate = pct(result.metrics.taskFailRate, 1);
|
|
206
|
+
const agentFailRate = pct(result.metrics.agentFailureRate, 2);
|
|
207
|
+
const miYieldRate = pct(mi.yieldRate, 1);
|
|
208
|
+
const medianDurMin = (wallTime.medianMs / 60000).toFixed(1);
|
|
209
|
+
const p95DurMin = (wallTime.p95Ms / 60000).toFixed(1);
|
|
210
|
+
const avgPromoted = totalRuns ? String(Math.round(cons.promoted / totalRuns)) : "0";
|
|
211
|
+
const chunkFail = `${num(cons.failedChunks)} / ${num(cons.totalChunks)}`;
|
|
212
|
+
// ── Meta (collect.py steps 10-11) ──────────────────────────────────────────
|
|
213
|
+
const sinceIso = result.since;
|
|
214
|
+
const reportDate = sinceIso.slice(0, 10);
|
|
215
|
+
const sinceHuman = sinceIso ? `${sinceIso.slice(0, 16).replace("T", " ")} UTC → now` : `last ${opts.window}`;
|
|
216
|
+
const reportTitle = reportDate ? `AKM Health Report — ${reportDate}` : "AKM Health Report";
|
|
217
|
+
const lastRun = runs[runs.length - 1];
|
|
218
|
+
const generatedAt = lastRun ? lastRun.completedAt || lastRun.startedAt || sinceIso : sinceIso;
|
|
219
|
+
const latest = [...runs].reverse().find((r) => r.ok) ?? lastRun;
|
|
220
|
+
// ── Status badges ──────────────────────────────────────────────────────────
|
|
221
|
+
const badgeByStatus = {
|
|
222
|
+
pass: { badge: "badge-pass", dot: "dot-pass", label: "PASS" },
|
|
223
|
+
warn: { badge: "badge-warn", dot: "dot-warn", label: "WARN" },
|
|
224
|
+
fail: { badge: "badge-fail", dot: "dot-fail", label: "FAIL" },
|
|
225
|
+
};
|
|
226
|
+
const badge = badgeByStatus[result.status];
|
|
227
|
+
const statusBadge = `<span class="badge-pill ${badge.badge}"><span class="dot ${badge.dot}"></span>${badge.label}</span>`;
|
|
228
|
+
const failOk = result.metrics.taskFailRate < 0.05;
|
|
229
|
+
const failBadge = `<span class="badge-pill ${failOk ? "badge-pass" : "badge-warn"}">` +
|
|
230
|
+
`<span class="dot ${failOk ? "dot-pass" : "dot-warn"}"></span>${taskFailRate} Fail Rate</span>`;
|
|
231
|
+
// ── Executive summary ──────────────────────────────────────────────────────
|
|
232
|
+
const li = (k, vHtml) => `<li><span class="k">${esc(k)}</span><span class="v">${vHtml}</span></li>`;
|
|
233
|
+
const trendLi = (k, d) => li(k, `<span class="trend-pill ${d === "flat" ? "flat" : d}">${d}</span>`);
|
|
234
|
+
const quickNumbers = [
|
|
235
|
+
li("Task fail rate", taskFailRate),
|
|
236
|
+
li("Agent fail rate", agentFailRate),
|
|
237
|
+
li("Improve completion", `${num(completed)} / ${num(invoked)} (${completionRate})`),
|
|
238
|
+
li("MI yield rate", miYieldRate),
|
|
239
|
+
li("MI written", num(miWritten)),
|
|
240
|
+
li("Consolidation promoted", num(cons.promoted)),
|
|
241
|
+
li("Consolidation judgedNoAction", num(cons.judgedNoAction)),
|
|
242
|
+
li("Chunk failure", chunkFail),
|
|
243
|
+
li("Median wall time", fmtMs(wallTime.medianMs)),
|
|
244
|
+
li("P95 wall time", fmtMs(wallTime.p95Ms)),
|
|
245
|
+
].join("");
|
|
246
|
+
const trendRows = [
|
|
247
|
+
trendLi("Decision quality", trend.decisionQuality),
|
|
248
|
+
trendLi("Output volume", trend.outputVolume),
|
|
249
|
+
trendLi("Failures", trend.failures),
|
|
250
|
+
trendLi("Latency", trend.latency),
|
|
251
|
+
].join("");
|
|
252
|
+
const deltaRows = [
|
|
253
|
+
li("Promoted", deltaPill(deltas, "improve.consolidation.promoted")),
|
|
254
|
+
li("MI written", deltaPill(deltas, "improve.memoryInference.written")),
|
|
255
|
+
li("MI yield", deltaPill(deltas, "improve.memoryInference.yieldRate")),
|
|
256
|
+
li("Median wall", deltaPill(deltas, "improve.wallTime.medianMs", true)),
|
|
257
|
+
li("P95 wall", deltaPill(deltas, "improve.wallTime.p95Ms", true)),
|
|
258
|
+
].join("");
|
|
259
|
+
const snapRows = latest
|
|
260
|
+
? [
|
|
261
|
+
li("Run id", `<code>${esc(latest.id.slice(0, 28))}</code>`),
|
|
262
|
+
li("Completed", `${esc((latest.completedAt || latest.startedAt).slice(0, 16).replace("T", " "))} UTC`),
|
|
263
|
+
li("Status", latest.ok ? "✅ ok" : "❌ failed"),
|
|
264
|
+
li("Wall time", fmtMs(latest.wallTimeMs)),
|
|
265
|
+
li("Reflect ok/fail", `${latest.reflectOk} / ${latest.reflectFailed}`),
|
|
266
|
+
li("Promoted", String(latest.promoted)),
|
|
267
|
+
li("judgedNoAction", String(latest.judgedNoAction)),
|
|
268
|
+
li("MI written", String(latest.miWritten)),
|
|
269
|
+
li("Graph entities/relations", `${latest.geEntities} / ${latest.geRelations}`),
|
|
270
|
+
].join("")
|
|
271
|
+
: '<li><span class="k">No runs in window</span><span class="v">—</span></li>';
|
|
272
|
+
const windowRows = [
|
|
273
|
+
li("Report window", esc(opts.window)),
|
|
274
|
+
li("Compare window", esc(opts.compare)),
|
|
275
|
+
li("Runs", `${num(totalRuns)} (${failedRuns} failed)`),
|
|
276
|
+
li("Stash derived", num(improve.memorySummary.derived)),
|
|
277
|
+
li("Stash eligible", num(improve.memorySummary.eligible)),
|
|
278
|
+
li("Pending proposals", String(proposals.length)),
|
|
279
|
+
li("Semantic search", sem.blocked ? "BLOCKED" : "OK"),
|
|
280
|
+
].join("");
|
|
281
|
+
const overallEmoji = trend.overall === "improving" ? "📈" : trend.overall === "degrading" ? "📉" : "↔️";
|
|
282
|
+
const execSummary = `
|
|
283
|
+
<h2>${overallEmoji} Executive Summary
|
|
284
|
+
<span class="badge-pill ${badge.badge}" style="font-size:11px;">
|
|
285
|
+
<span class="dot ${badge.dot}"></span>${badge.label}</span></h2>
|
|
286
|
+
<div class="exec-grid">
|
|
287
|
+
<div>
|
|
288
|
+
<h4>Quick Numbers</h4>
|
|
289
|
+
<ul>${quickNumbers}</ul>
|
|
290
|
+
</div>
|
|
291
|
+
<div>
|
|
292
|
+
<h4>Trend vs prior ${esc(opts.compare)}</h4>
|
|
293
|
+
<ul>${trendRows}</ul>
|
|
294
|
+
<h4 style="margin-top:14px;">Period-over-period deltas</h4>
|
|
295
|
+
<ul>${deltaRows}</ul>
|
|
296
|
+
</div>
|
|
297
|
+
<div>
|
|
298
|
+
<h4>Current Run Snapshot</h4>
|
|
299
|
+
<ul>${snapRows}</ul>
|
|
300
|
+
</div>
|
|
301
|
+
<div>
|
|
302
|
+
<h4>Window</h4>
|
|
303
|
+
<ul>${windowRows}</ul>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
<div class="overall">Overall trend: <b>${esc(trend.overall)}</b> ${overallEmoji}
|
|
307
|
+
· based on decision quality, output volume, failures, and latency vs the prior window.</div>`.trim();
|
|
308
|
+
// ── KPI cards ──────────────────────────────────────────────────────────────
|
|
309
|
+
const semValue = sem.blocked ? "BLOCKED" : "OK";
|
|
310
|
+
const semColor = sem.blocked ? "yellow" : "green";
|
|
311
|
+
const semStyle = sem.blocked ? "font-size:18px;" : "";
|
|
312
|
+
const kpiCard = (color, label, value, sub, valueStyle = "") => `<div class="kpi-card ${color}">
|
|
313
|
+
<div class="label">${label}</div>
|
|
314
|
+
<div class="value"${valueStyle ? ` style="${valueStyle}"` : ""}>${value}</div>
|
|
315
|
+
<div class="sub">${sub}</div>
|
|
316
|
+
</div>`;
|
|
317
|
+
const kpiCards = [
|
|
318
|
+
kpiCard(failedRuns === 0 ? "green" : "yellow", "Completion Rate", completionRate, `${num(completed)} / ${num(invoked)} invoked`),
|
|
319
|
+
kpiCard(failedRuns === 0 ? "green" : "red", "Failed Runs", String(failedRuns), `of ${num(totalRuns)} runs · ${taskFailRate} task fail`),
|
|
320
|
+
kpiCard("blue", "Total Promoted", num(cons.promoted), `avg ${avgPromoted} / run`),
|
|
321
|
+
kpiCard("blue", "MI Written", num(miWritten), `${miYieldRate} yield rate`),
|
|
322
|
+
kpiCard("purple", "Graph Entities", num(ge.entities), `+${num(ge.relations)} relations`),
|
|
323
|
+
kpiCard("green", "Stash Derived", num(improve.memorySummary.derived), `of ${num(improve.memorySummary.eligible)} eligible`),
|
|
324
|
+
kpiCard("yellow", "Median Duration", `${medianDurMin}m`, `p95 = ${p95DurMin}m`),
|
|
325
|
+
kpiCard(semColor, "Semantic Search", semValue, esc(sem.detail), semStyle),
|
|
326
|
+
kpiCard("yellow", "Pending Proposals", String(proposals.length), `from ${esc(opts.window)} batch`),
|
|
327
|
+
].join("\n");
|
|
328
|
+
// ── Chart payload ──────────────────────────────────────────────────────────
|
|
329
|
+
const distillReasons = [...new Set(runs.flatMap((r) => Object.keys(r.distillByReason)))].sort();
|
|
330
|
+
const runsJsConst = `const RUNS = ${JSON.stringify(runs)};`;
|
|
331
|
+
// ── Summary table rows ─────────────────────────────────────────────────────
|
|
332
|
+
const summaryRows = [
|
|
333
|
+
["Task fail rate", taskFailRate, "flat"],
|
|
334
|
+
["Agent fail rate", agentFailRate, "flat"],
|
|
335
|
+
["Improve completion", `${num(completed)} / ${num(invoked)}`, "flat"],
|
|
336
|
+
["MI yield rate", miYieldRate, trend.decisionQuality],
|
|
337
|
+
["MI written", num(miWritten), trend.outputVolume],
|
|
338
|
+
["Consolidation promoted", num(cons.promoted), trend.outputVolume],
|
|
339
|
+
["Consolidation merged", num(cons.merged), "flat"],
|
|
340
|
+
["Consolidation deleted", num(cons.deleted), "flat"],
|
|
341
|
+
["Consolidation contradicted", num(cons.contradicted), "flat"],
|
|
342
|
+
["Consolidation judgedNoAction", num(cons.judgedNoAction), "flat"],
|
|
343
|
+
["Chunk failure", chunkFail, "flat"],
|
|
344
|
+
["Graph entities", num(ge.entities), "up"],
|
|
345
|
+
["Graph relations", num(ge.relations), "up"],
|
|
346
|
+
["Stash derived", num(improve.memorySummary.derived), "up"],
|
|
347
|
+
["Median wall time", fmtMs(wallTime.medianMs), trend.latency],
|
|
348
|
+
["P95 wall time", fmtMs(wallTime.p95Ms), trend.latency],
|
|
349
|
+
];
|
|
350
|
+
const summaryRowsHtml = summaryRows
|
|
351
|
+
.map(([label, value, t]) => ` <tr><td>${esc(label)}</td><td>${esc(value)}</td>` +
|
|
352
|
+
`<td class="trend ${trendClass(t)}">${trendLabel(t)}</td></tr>`)
|
|
353
|
+
.join("\n");
|
|
354
|
+
// ── Advisory cards ─────────────────────────────────────────────────────────
|
|
355
|
+
const advisoryParts = [];
|
|
356
|
+
for (const a of result.advisories) {
|
|
357
|
+
if (a.status !== "warn" && a.status !== "fail")
|
|
358
|
+
continue;
|
|
359
|
+
advisoryParts.push(advisoryCard(a.status === "fail" ? "fail" : "warn", a.status === "fail" ? "🔴" : "⚠️", esc(a.name), esc(a.message)));
|
|
360
|
+
}
|
|
361
|
+
if (sem.blocked) {
|
|
362
|
+
advisoryParts.push(advisoryCard("warn", "⚠️", "Semantic search blocked", `Embedding provider unreachable. ${esc(sem.detail)}. Curate falls back to keyword search — relevance scoring degraded.`));
|
|
363
|
+
}
|
|
364
|
+
if (proposals.length > 0) {
|
|
365
|
+
advisoryParts.push(advisoryCard("warn", "⚠️", `${proposals.length} proposals pending (drain needed)`, "Run <code>akm proposal list</code> to review and drain."));
|
|
366
|
+
}
|
|
367
|
+
if (result.sessionLogAdvisories.length > 0) {
|
|
368
|
+
const patterns = result.sessionLogAdvisories
|
|
369
|
+
.slice(0, 6)
|
|
370
|
+
.map((p) => `<li>${esc(p.topic)}</li>`)
|
|
371
|
+
.join("");
|
|
372
|
+
advisoryParts.push('<div class="advisory" style="border-left:3px solid var(--accent);">' +
|
|
373
|
+
'<div class="advisory-icon">ℹ️</div><div class="advisory-body">' +
|
|
374
|
+
`<div class="title">${result.sessionLogAdvisories.length} session-log note(s) (informational)</div>` +
|
|
375
|
+
`<div class="desc"><ul style="margin:4px 0 0 16px;padding:0;">${patterns}</ul></div></div></div>`);
|
|
376
|
+
}
|
|
377
|
+
const advisoryCardsHtml = advisoryParts.length > 0
|
|
378
|
+
? advisoryParts.join("\n")
|
|
379
|
+
: passCard("No active advisories", "All checks passed for this window.");
|
|
380
|
+
// ── Proposal rows ──────────────────────────────────────────────────────────
|
|
381
|
+
const proposalRowsHtml = proposals.length > 0
|
|
382
|
+
? proposals
|
|
383
|
+
.map((p, i) => {
|
|
384
|
+
const tagCls = p.source === "extract" ? "tag-extract" : "tag-consolidate";
|
|
385
|
+
const ts = p.createdAt.slice(0, 16).replace("T", " ");
|
|
386
|
+
return (`<tr><td>${i + 1}</td><td><code>${esc(p.ref)}</code></td>` +
|
|
387
|
+
`<td><span class="tag ${tagCls}">${esc(p.source)}</span></td>` +
|
|
388
|
+
`<td>${esc(ts)}</td></tr>`);
|
|
389
|
+
})
|
|
390
|
+
.join("\n")
|
|
391
|
+
: '<tr><td colspan="4" style="text-align:center;color:var(--muted);">No pending proposals</td></tr>';
|
|
392
|
+
// ── What to watch ──────────────────────────────────────────────────────────
|
|
393
|
+
const watchParts = [];
|
|
394
|
+
for (const a of result.advisories) {
|
|
395
|
+
if (a.status !== "warn" && a.status !== "fail")
|
|
396
|
+
continue;
|
|
397
|
+
const prio = a.status === "fail" ? "P1" : "P2";
|
|
398
|
+
watchParts.push(advisoryCard(a.status === "fail" ? "fail" : "warn", a.status === "fail" ? "🔴" : "🟡", `${esc(a.name)} (${prio})`, esc(a.message)));
|
|
399
|
+
}
|
|
400
|
+
if (sem.blocked) {
|
|
401
|
+
watchParts.push(advisoryCard("warn", "🟡", "Embedding server unreachable (P2)", "Curate quality and semantic ranking are degraded. Check the embedding endpoint configured in config.json."));
|
|
402
|
+
}
|
|
403
|
+
if (proposals.length > 0) {
|
|
404
|
+
const bySource = new Map();
|
|
405
|
+
for (const p of proposals)
|
|
406
|
+
bySource.set(p.source, (bySource.get(p.source) ?? 0) + 1);
|
|
407
|
+
const srcSummary = [...bySource.entries()]
|
|
408
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
409
|
+
.map(([source, count]) => `${count} via ${esc(source)}`)
|
|
410
|
+
.join(", ");
|
|
411
|
+
watchParts.push(advisoryCard("warn", "🟡", `Drain ${proposals.length} pending proposals (P2)`, `Proposals generated this batch (${srcSummary}). Run <code>akm proposal list</code> before the queue grows further.`));
|
|
412
|
+
}
|
|
413
|
+
if (wallTime.p95Ms && wallTime.medianMs && wallTime.p95Ms / wallTime.medianMs > 2.5) {
|
|
414
|
+
watchParts.push(advisoryCard("warn", "🟡", `High tail latency (P3): p95=${fmtMs(wallTime.p95Ms)}, median=${fmtMs(wallTime.medianMs)}`, "P95 is well above median. Consolidation/LLM phase dominates wall time on slow runs. " +
|
|
415
|
+
"Check for slow chunks or LLM rate limiting."));
|
|
416
|
+
}
|
|
417
|
+
if (failedRuns > 0) {
|
|
418
|
+
watchParts.push(advisoryCard("warn", "🟡", `${failedRuns} failed run(s) in window (P2)`, `Task fail rate ${taskFailRate}. Inspect failed runs (ok=false) for early-exit or harness errors.`));
|
|
419
|
+
}
|
|
420
|
+
const watchItemsHtml = watchParts.length > 0
|
|
421
|
+
? watchParts.join("\n")
|
|
422
|
+
: passCard("Nothing critical to watch", "All indicators are within normal range.");
|
|
423
|
+
// ── Commands used ──────────────────────────────────────────────────────────
|
|
424
|
+
const commandsHtml = [
|
|
425
|
+
` <div><span>akm health --since=${esc(opts.window)} --group-by run --format json</span></div>`,
|
|
426
|
+
` <div><span>akm health --since=${esc(opts.window)} --window-compare=${esc(opts.compare)} --format json</span></div>`,
|
|
427
|
+
" <div><span>akm proposal list</span></div>",
|
|
428
|
+
].join("\n");
|
|
429
|
+
return {
|
|
430
|
+
"%%ECHARTS_TAG%%": buildEchartsTag(opts),
|
|
431
|
+
"%%REPORT_TITLE%%": esc(reportTitle),
|
|
432
|
+
"%%WINDOW%%": esc(opts.window),
|
|
433
|
+
"%%SINCE_HUMAN%%": esc(sinceHuman),
|
|
434
|
+
"%%RUN_COUNT%%": num(totalRuns),
|
|
435
|
+
"%%STATUS_BADGE_HTML%%": `${statusBadge}\n ${failBadge}`,
|
|
436
|
+
"%%EXEC_SUMMARY_HTML%%": execSummary,
|
|
437
|
+
"%%KPI_CARDS_HTML%%": kpiCards,
|
|
438
|
+
"%%RUNS_JS_CONST%%": runsJsConst,
|
|
439
|
+
"%%DISTILL_REASONS_JSON%%": JSON.stringify(distillReasons),
|
|
440
|
+
"%%SUMMARY_ROWS_HTML%%": summaryRowsHtml,
|
|
441
|
+
"%%ADVISORY_CARDS_HTML%%": advisoryCardsHtml,
|
|
442
|
+
"%%PROPOSAL_ROWS_HTML%%": proposalRowsHtml,
|
|
443
|
+
"%%PROPOSAL_COUNT%%": String(proposals.length),
|
|
444
|
+
"%%WATCH_ITEMS_HTML%%": watchItemsHtml,
|
|
445
|
+
"%%COMMANDS_HTML%%": commandsHtml,
|
|
446
|
+
"%%GENERATED_AT%%": esc(generatedAt),
|
|
447
|
+
};
|
|
448
|
+
}
|