akm-cli 0.10.261004-beta → 0.10.261006-beta
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 +58 -0
- package/STABILITY.md +9 -0
- package/dist/assets/ui/app.js +91 -0
- package/dist/assets/ui/asset.js +67 -0
- package/dist/assets/ui/bundles.js +80 -0
- package/dist/assets/ui/changes.js +32 -0
- package/dist/assets/ui/charts.js +93 -0
- package/dist/assets/ui/config.js +69 -0
- package/dist/assets/ui/diff-view.js +202 -0
- package/dist/assets/ui/diff.js +261 -0
- package/dist/assets/ui/index.html +30 -0
- package/dist/assets/ui/lib.js +238 -0
- package/dist/assets/ui/markdown.js +103 -0
- package/dist/assets/ui/overview.js +145 -0
- package/dist/assets/ui/proposal-actions.js +273 -0
- package/dist/assets/ui/proposals.js +219 -0
- package/dist/assets/ui/search.js +62 -0
- package/dist/assets/ui/styles.css +1415 -0
- package/dist/cli.js +3 -0
- package/dist/commands/health/checks.js +5 -3
- package/dist/commands/health/egress.js +5 -1
- package/dist/commands/health/engine-usage.js +2 -2
- package/dist/commands/health/llm-usage.js +2 -2
- package/dist/commands/health/windows.js +5 -5
- package/dist/commands/health.js +40 -8
- package/dist/commands/improve/improve.js +17 -4
- package/dist/commands/improve/reflect-noise.js +4 -0
- package/dist/commands/improve/reflect.js +14 -5
- package/dist/commands/proposal/drain.js +13 -1
- package/dist/commands/proposal/proposal-cli.js +23 -6
- package/dist/commands/proposal/proposal.js +58 -11
- package/dist/commands/proposal/repository.js +67 -31
- package/dist/commands/read/search.js +2 -1
- package/dist/commands/read/show.js +8 -6
- package/dist/commands/ui/api.js +556 -0
- package/dist/commands/ui/git-read.js +101 -0
- package/dist/commands/ui/open-browser.js +30 -0
- package/dist/commands/ui/proposal-actions.js +179 -0
- package/dist/commands/ui/redact.js +88 -0
- package/dist/commands/ui/server.js +352 -0
- package/dist/commands/ui/ui-cli.js +62 -0
- package/dist/core/write-source.js +9 -5
- package/dist/indexer/search/db-search.js +2 -1
- package/dist/output/format-exempt.js +3 -0
- package/dist/sources/providers/git-stash.js +46 -15
- package/dist/storage/repositories/improve-runs-repository.js +6 -0
- package/dist/storage/repositories/metrics-repository.js +17 -0
- package/docs/architecture/0.10-process-rules.md +4 -5
- package/docs/architecture/improvement.md +9 -5
- package/docs/architecture/internals/improve-workflow.md +2 -2
- package/docs/reference/README.md +1 -0
- package/docs/reference/cli.md +62 -3
- package/docs/reference/ui-api.md +576 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,64 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.10.261006] - 2026-10-16
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`akm ui` serves a local web UI.** It listens on `127.0.0.1` (a random free port unless `--port` is given),
|
|
14
|
+
prints a URL that carries a per-launch token and opens your browser; `--no-open` only prints it. It reads health and
|
|
15
|
+
metrics, bundles with git status, the effective config, the proposal queue, recent changes and search through the
|
|
16
|
+
functions the CLI uses, and browsing writes nothing: no usage events, no index build, no config or database writes.
|
|
17
|
+
The page has Overview (health, metrics, recent improve runs, 14-day usage), Bundles, Config (with a source badge per
|
|
18
|
+
key), Proposals (with diffs), Changes and Search with an asset view. It is plain HTML, CSS and JavaScript with no
|
|
19
|
+
external requests, backed by a JSON API (`docs/reference/ui-api.md`). Experimental.
|
|
20
|
+
- **`akm ui` can accept and reject proposals.** A proposal's page has Accept (after an inline confirmation) and Reject
|
|
21
|
+
(with a required reason); the pending list has a checkbox per row and an "Accept selected" bar that accepts several
|
|
22
|
+
proposals in one commit. These are the UI's only writes, through three `POST` routes that call the functions
|
|
23
|
+
`akm proposal accept` and `reject` call, so a decision is recorded and committed exactly as on the command line.
|
|
24
|
+
A write needs the token in the `X-Akm-Ui-Token` header (not the URL), a JSON content type and an `Origin` that is the
|
|
25
|
+
server's own address; a stale target, an already-decided proposal and a held write lease come back as `409`.
|
|
26
|
+
- **`akm ui` shows a proposal's diff side by side.** The proposal page lines the current asset up against the proposed
|
|
27
|
+
text (current on the left, proposed on the right, line numbers on both, changed words highlighted inside a changed
|
|
28
|
+
line) and collapses long unchanged runs behind "Show N unchanged lines". A "Side by side | Inline" switch above the
|
|
29
|
+
diff picks the layout; the choice is remembered in the browser, and a narrow screen starts inline. The unified diff
|
|
30
|
+
`akm proposal diff` prints pairs lines by position, so one frontmatter field added near the top used to read as a
|
|
31
|
+
rewrite of the whole file; the page now aligns the two texts itself. `GET /api/proposals/:id` returns `diff.before`
|
|
32
|
+
and `diff.after` for this (never for env or secret assets); `diff.unified` is unchanged.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **Reflect's missing-H1 title is now a valid, byte-preserving edit (#1183).** When a reflect patch carried a title
|
|
37
|
+
and the body had no level-1 heading, reflect inserted `# <title>` but also stripped every leading blank line of the
|
|
38
|
+
body, so reviewers and gates reading the rules as "frontmatter only" rejected those proposals as body edits.
|
|
39
|
+
Reflect now inserts only the heading and one blank line, at the body's first non-blank line and in its line ending;
|
|
40
|
+
the rest of the body is unchanged. The rules doc now says reflect edits frontmatter
|
|
41
|
+
plus at most a missing H1.
|
|
42
|
+
- **`akm health` no longer prints credentials carried in configured URLs.** The `egress-endpoints` advisory listed
|
|
43
|
+
every registry, source, LLM and embedding URL verbatim, so a `?token=…` query or a `user:password@` in a git URL
|
|
44
|
+
appeared in the health output, its JSON and the HTML/markdown reports. Each entry now goes through the shared
|
|
45
|
+
credential redaction, which keeps the host and path and replaces the secret with `[REDACTED]`.
|
|
46
|
+
|
|
47
|
+
## [0.10.261005] - 2026-10-14
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
|
|
51
|
+
- **A process that accepts several proposals commits once, not once per proposal.** `akm proposal drain`,
|
|
52
|
+
`akm proposal accept --generator` and the triage drain inside `akm improve` used to make one commit (and, on a git-kind
|
|
53
|
+
bundle, one push) per accepted proposal. They now commit once when they finish, with a subject such as
|
|
54
|
+
`akm proposal drain: 37 accepted (consolidate-pair 30, consolidate 7)` and one `<id8> <source> <ref>` body line per
|
|
55
|
+
proposal. In `akm improve`, triage accepts join the run's end-of-run batch commit. A single `akm proposal accept <id>`
|
|
56
|
+
is still one commit, and `akm proposal accept <id> <id> ...` now accepts several ids in one process and commits them
|
|
57
|
+
once; an id that fails is reported in the batch result (exit 1) and the others still land.
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- **`akm sync` pushes commits that were made earlier.** With a clean working tree it fetched and merged the upstream
|
|
62
|
+
but never pushed, so a commit made outside `akm sync` (an `akm proposal accept` on a filesystem bundle, an improve
|
|
63
|
+
run killed before its end-of-run sync, a manual commit) stayed ahead of the remote and `akm sync` reported
|
|
64
|
+
`pushed: false`. It now pushes those commits when the bundle is writable, has an upstream and push is allowed, and
|
|
65
|
+
reports `pushed: true`. `--no-push`, read-only bundles and branches without an upstream are unchanged.
|
|
66
|
+
|
|
9
67
|
## [0.10.261004] - 2026-10-13
|
|
10
68
|
|
|
11
69
|
### Fixed
|
package/STABILITY.md
CHANGED
|
@@ -88,6 +88,7 @@ enumeration of the whole `proposal` noun group.
|
|
|
88
88
|
| `akm index` | Stable | |
|
|
89
89
|
| `akm health` | Evolving | Exit codes are Evolving; report *content* and rendered `md`/`html` layout are Experimental — do not script against report layout. |
|
|
90
90
|
| `akm metrics` | Experimental | New in 0.9.28. Report content, JSON shape and the `html` dashboard may change. |
|
|
91
|
+
| `akm ui` | Experimental | New in 0.10. The command, its flags, the page and the `/api/` JSON it reads may change in any release. Read-only except that proposals can be accepted (singly or in a batch) and rejected from it. |
|
|
91
92
|
| `akm info` | Stable | |
|
|
92
93
|
| `akm bundle create` | Stable | |
|
|
93
94
|
| `akm bundle add` | Stable | |
|
|
@@ -403,6 +404,14 @@ for scripted use.
|
|
|
403
404
|
- **`akm metrics`** — new in 0.9.28. What it reports, its JSON shape and the
|
|
404
405
|
`--format html` dashboard may change in any release; do not script against
|
|
405
406
|
them.
|
|
407
|
+
- **`akm ui`** — new in 0.10. A local web UI over the same data
|
|
408
|
+
`akm health`, `akm metrics`, `akm config`, `akm proposal`, `akm search` and
|
|
409
|
+
`akm show` report. It is read-only except that proposals can be accepted
|
|
410
|
+
(singly or several at once, one commit) and rejected from it, through the
|
|
411
|
+
same functions `akm proposal accept` / `reject` call. The command's flags, the page, and the `/api/` JSON the
|
|
412
|
+
page reads (documented in `docs/reference/ui-api.md`) may change in any
|
|
413
|
+
release; do not script against the API. The CLI's JSON output remains the
|
|
414
|
+
scriptable surface.
|
|
406
415
|
- **`lesson` asset type** — schema (`when_to_use`, `description`) is
|
|
407
416
|
stable, but lesson-distillation triggers and ranking are tuning targets.
|
|
408
417
|
- **`--detail agent`** — the agent projection, implemented on `search`, `show`,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// akm ui client: hash router and shell. Pages are modules that fetch from the read-only JSON API and return markup
|
|
2
|
+
// built with the escaping `html` tag; this file only mounts it.
|
|
3
|
+
|
|
4
|
+
import { errorState, getMeta, html, initToken, loadingState, mount } from "./lib.js";
|
|
5
|
+
|
|
6
|
+
initToken();
|
|
7
|
+
|
|
8
|
+
const ROUTES = [
|
|
9
|
+
{ name: "overview", label: "Overview", load: () => import("./overview.js"), render: (m, c) => m.render(c) },
|
|
10
|
+
{ name: "bundles", label: "Bundles", load: () => import("./bundles.js"), render: (m, c) => m.render(c) },
|
|
11
|
+
{ name: "config", label: "Config", load: () => import("./config.js"), render: (m, c) => m.render(c) },
|
|
12
|
+
{
|
|
13
|
+
name: "proposals",
|
|
14
|
+
label: "Proposals",
|
|
15
|
+
load: () => import("./proposals.js"),
|
|
16
|
+
render: (m, c) => (c.param ? m.renderDetail(c) : m.renderList(c)),
|
|
17
|
+
},
|
|
18
|
+
{ name: "changes", label: "Changes", load: () => import("./changes.js"), render: (m, c) => m.render(c) },
|
|
19
|
+
{ name: "search", label: "Search", load: () => import("./search.js"), render: (m, c) => m.render(c) },
|
|
20
|
+
{ name: "asset", label: "Asset", nav: "search", load: () => import("./asset.js"), render: (m, c) => m.render(c) },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const main = document.getElementById("app");
|
|
24
|
+
let sequence = 0;
|
|
25
|
+
|
|
26
|
+
function parseHash() {
|
|
27
|
+
const raw = location.hash.replace(/^#/, "") || "/overview";
|
|
28
|
+
const [path, query = ""] = raw.split("?");
|
|
29
|
+
const [name = "overview", ...rest] = path.split("/").filter(Boolean);
|
|
30
|
+
let param = rest.join("/");
|
|
31
|
+
try {
|
|
32
|
+
param = decodeURIComponent(param);
|
|
33
|
+
} catch {
|
|
34
|
+
// Keep the raw segment.
|
|
35
|
+
}
|
|
36
|
+
return { name, param, query: new URLSearchParams(query) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function markNav(active) {
|
|
40
|
+
for (const link of document.querySelectorAll("nav a[data-route]")) {
|
|
41
|
+
if (link.dataset.route === active) link.setAttribute("aria-current", "page");
|
|
42
|
+
else link.removeAttribute("aria-current");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function route() {
|
|
47
|
+
const mine = ++sequence;
|
|
48
|
+
const { name, param, query } = parseHash();
|
|
49
|
+
const entry = ROUTES.find((r) => r.name === name);
|
|
50
|
+
markNav(entry ? (entry.nav ?? entry.name) : "");
|
|
51
|
+
document.title = `${entry ? entry.label : "Not found"} - akm ui`;
|
|
52
|
+
main.setAttribute("aria-busy", "true");
|
|
53
|
+
mount(main, loadingState());
|
|
54
|
+
const ctx = { query, param, after: [] };
|
|
55
|
+
try {
|
|
56
|
+
if (!entry) {
|
|
57
|
+
mount(
|
|
58
|
+
main,
|
|
59
|
+
html`<div class="state state-empty"><p class="state-title">No such page</p><p>Try the <a href="#/overview">overview</a>.</p></div>`,
|
|
60
|
+
);
|
|
61
|
+
} else {
|
|
62
|
+
const page = await entry.load();
|
|
63
|
+
const markup = await entry.render(page, ctx);
|
|
64
|
+
if (mine !== sequence) return;
|
|
65
|
+
mount(main, markup);
|
|
66
|
+
for (const fn of ctx.after) fn(main);
|
|
67
|
+
window.scrollTo(0, 0);
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (mine === sequence) mount(main, errorState(error));
|
|
71
|
+
} finally {
|
|
72
|
+
if (mine === sequence) main.removeAttribute("aria-busy");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function showMeta() {
|
|
77
|
+
const target = document.getElementById("nav-meta");
|
|
78
|
+
try {
|
|
79
|
+
const meta = await getMeta();
|
|
80
|
+
mount(
|
|
81
|
+
target,
|
|
82
|
+
html`<span class="mono">akm ${meta.version}</span><span class="muted small">${meta.readOnly ? "read-only" : ""} ${meta.runtime}</span>`,
|
|
83
|
+
);
|
|
84
|
+
} catch {
|
|
85
|
+
mount(target, html``);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
window.addEventListener("hashchange", route);
|
|
90
|
+
showMeta();
|
|
91
|
+
route();
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Asset view: metadata, frontmatter table, the body as safe markdown, links and feedback.
|
|
2
|
+
|
|
3
|
+
import { api, assetHref, badge, definitionList, emptyState, html, pageHeader, tableWrap, when } from "./lib.js";
|
|
4
|
+
import { markdown, splitFrontmatter } from "./markdown.js";
|
|
5
|
+
|
|
6
|
+
function linksBlock(links) {
|
|
7
|
+
const groups = Object.entries(links ?? {}).filter(([, kinds]) => kinds && typeof kinds === "object");
|
|
8
|
+
const rows = [];
|
|
9
|
+
for (const [direction, kinds] of groups) {
|
|
10
|
+
for (const [kind, info] of Object.entries(kinds)) {
|
|
11
|
+
const refs = Array.isArray(info?.refs) ? info.refs : [];
|
|
12
|
+
rows.push(
|
|
13
|
+
html`<tr><td>${direction}</td><td>${kind}</td><td>${refs.map((r) => html`<a class="mono wrap" href="${assetHref(r)}">${r}</a> `)}${info?.total > refs.length ? html`<span class="muted">${info.total} total</span>` : ""}</td></tr>`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (rows.length === 0) return "";
|
|
18
|
+
return html`<section><h2>Links</h2>${tableWrap(html`<table><thead><tr><th>Direction</th><th>Kind</th><th>Assets</th></tr></thead><tbody>${rows}</tbody></table>`)}</section>`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function feedbackBlock(feedback) {
|
|
22
|
+
const counts = feedback?.counts ?? { positive: 0, negative: 0 };
|
|
23
|
+
const history = feedback?.history ?? [];
|
|
24
|
+
return html`<section><h2>Feedback</h2>
|
|
25
|
+
<p><span class="num big text-ok">${counts.positive ?? 0}</span> <span class="muted">positive</span> <span class="num big text-fail">${counts.negative ?? 0}</span> <span class="muted">negative</span></p>
|
|
26
|
+
${
|
|
27
|
+
history.length === 0
|
|
28
|
+
? emptyState("No feedback recorded for this asset.")
|
|
29
|
+
: tableWrap(
|
|
30
|
+
html`<table><thead><tr><th>When</th><th>Signal</th><th>Source</th><th>Reason</th><th>Tags</th></tr></thead><tbody>${history.map(
|
|
31
|
+
(e) =>
|
|
32
|
+
html`<tr><td>${when(e.at)}</td><td>${badge(e.signal, e.signal === "positive" ? "ok" : "fail")}</td><td>${e.source}</td><td class="wrap">${e.reason ?? ""}</td><td class="mono small">${(e.tags ?? []).join(", ")}</td></tr>`,
|
|
33
|
+
)}</tbody></table>`,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
</section>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function render(ctx) {
|
|
40
|
+
const ref = ctx.query.get("ref") ?? "";
|
|
41
|
+
if (!ref)
|
|
42
|
+
return html`${pageHeader("Asset")}${emptyState("No asset selected.", html`Pick one from <a href="#/search">search</a>.`)}`;
|
|
43
|
+
const data = await api("/api/asset", { ref });
|
|
44
|
+
const back = html`<p class="crumbs"><a href="#/search">Search</a> / asset</p>`;
|
|
45
|
+
if (data.redacted) {
|
|
46
|
+
return html`${back}${pageHeader(html`<span class="mono wrap">${data.ref}</span>`)}<div class="badges">${badge(data.type, "info")}${badge("redacted", "warn")}</div>
|
|
47
|
+
<div class="notice">${data.note}</div>`;
|
|
48
|
+
}
|
|
49
|
+
const asset = data.asset;
|
|
50
|
+
const { fields, body } = splitFrontmatter(asset.content);
|
|
51
|
+
return html`${back}${pageHeader(html`<span class="mono wrap">${asset.ref}</span>`)}
|
|
52
|
+
<div class="badges">${badge(asset.type, "info")}${asset.origin ? badge(asset.origin, "neutral") : ""}${asset.editable ? badge("editable", "ok") : badge("read-only", "neutral")}</div>
|
|
53
|
+
<section>${definitionList([
|
|
54
|
+
["Name", html`<span class="mono">${asset.name}</span>`],
|
|
55
|
+
[
|
|
56
|
+
"Durable ref",
|
|
57
|
+
data.durableRef
|
|
58
|
+
? html`<span class="mono wrap">${data.durableRef}</span>`
|
|
59
|
+
: html`<span class="muted">not indexed</span>`,
|
|
60
|
+
],
|
|
61
|
+
["Path", html`<span class="mono wrap">${asset.path}</span>`],
|
|
62
|
+
])}</section>
|
|
63
|
+
${fields.length > 0 ? html`<section><h2>Frontmatter</h2>${tableWrap(html`<table class="fm"><tbody>${fields.map(([k, v]) => html`<tr><th scope="row" class="mono">${k}</th><td class="mono wrap pre-wrap">${v}</td></tr>`)}</tbody></table>`)}</section>` : ""}
|
|
64
|
+
<section><h2>Content</h2>${body.trim() === "" ? emptyState("This asset has no body.") : markdown(body)}</section>
|
|
65
|
+
${linksBlock(asset.links)}
|
|
66
|
+
${feedbackBlock(data.feedback)}`;
|
|
67
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Bundles: one card per configured bundle with git status and asset counts, plus the index state.
|
|
2
|
+
|
|
3
|
+
import { proportionBar } from "./charts.js";
|
|
4
|
+
import { api, badge, definitionList, emptyState, fmtNum, fmtPct, html, pageHeader, when } from "./lib.js";
|
|
5
|
+
|
|
6
|
+
function gitBlock(git) {
|
|
7
|
+
if (!git) return html`<p class="muted">Not a git repository.</p>`;
|
|
8
|
+
const sync =
|
|
9
|
+
git.upstream === null
|
|
10
|
+
? html`<span class="muted">no upstream</span>`
|
|
11
|
+
: html`<span class="num">${git.ahead} ahead, ${git.behind} behind</span> <span class="muted mono">${git.upstream}</span>`;
|
|
12
|
+
return definitionList([
|
|
13
|
+
[
|
|
14
|
+
"Branch",
|
|
15
|
+
git.branch ? html`<span class="mono">${git.branch}</span>` : html`<span class="muted">detached HEAD</span>`,
|
|
16
|
+
],
|
|
17
|
+
["Remote", sync],
|
|
18
|
+
[
|
|
19
|
+
"Uncommitted",
|
|
20
|
+
git.dirtyPaths > 0 ? badge(`${git.dirtyPaths} paths`, "warn") : html`<span class="muted">clean</span>`,
|
|
21
|
+
],
|
|
22
|
+
[
|
|
23
|
+
"Last commit",
|
|
24
|
+
git.lastCommit
|
|
25
|
+
? html`${when(git.lastCommit.time)} <span class="mono small">${git.lastCommit.sha.slice(0, 8)}</span><div class="small">${git.lastCommit.subject}</div>`
|
|
26
|
+
: html`<span class="muted">none</span>`,
|
|
27
|
+
],
|
|
28
|
+
]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function card(bundle) {
|
|
32
|
+
return html`<article class="card">
|
|
33
|
+
<header class="card-head"><h3>${bundle.name}</h3>${badge(bundle.kind, "info")}${bundle.default ? badge("default", "neutral") : ""}${bundle.writable ? badge("writable", "ok") : badge("read-only", "neutral")}${bundle.exists ? "" : badge("missing", "fail")}</header>
|
|
34
|
+
${definitionList([
|
|
35
|
+
["Path", bundle.path ? html`<span class="mono">${bundle.path}</span>` : html`<span class="muted">none</span>`],
|
|
36
|
+
[
|
|
37
|
+
"Source",
|
|
38
|
+
bundle.source?.locator === bundle.path
|
|
39
|
+
? html`<span class="muted">${bundle.source?.kind ?? "unknown"} path</span>`
|
|
40
|
+
: html`<span class="mono">${bundle.source?.locator ?? ""}</span> <span class="muted">(${bundle.source?.kind ?? "unknown"})</span>`,
|
|
41
|
+
],
|
|
42
|
+
["Version", bundle.version],
|
|
43
|
+
["Assets", html`<span class="num">${fmtNum(bundle.itemCount)}</span>`],
|
|
44
|
+
])}
|
|
45
|
+
${proportionBar(bundle.byType ?? {})}
|
|
46
|
+
<h4>Git</h4>${gitBlock(bundle.git)}
|
|
47
|
+
</article>`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const plain = (value) => (typeof value === "string" ? value : JSON.stringify(value));
|
|
51
|
+
|
|
52
|
+
function indexPanel(index) {
|
|
53
|
+
const semantic = index.semanticSearch;
|
|
54
|
+
return html`<section><h2>Index</h2><div class="card">
|
|
55
|
+
${definitionList([
|
|
56
|
+
["Entries", html`<span class="num">${fmtNum(index.entryCount)}</span>`],
|
|
57
|
+
["Last built", index.lastBuiltAt ? when(index.lastBuiltAt) : html`<span class="muted">never</span>`],
|
|
58
|
+
[
|
|
59
|
+
"Embeddings",
|
|
60
|
+
index.hasEmbeddings
|
|
61
|
+
? html`<span class="num">${fmtNum(index.embeddingCount)}</span> (${fmtPct(index.embeddingCoverage)} coverage)`
|
|
62
|
+
: html`<span class="muted">none</span>`,
|
|
63
|
+
],
|
|
64
|
+
[
|
|
65
|
+
"Semantic search",
|
|
66
|
+
semantic ? html`<span class="mono">${semantic.mode}</span> <span class="muted">${semantic.status}</span>` : "",
|
|
67
|
+
],
|
|
68
|
+
["Unavailable", index.unavailable ? html`<span class="mono small">${plain(index.unavailable)}</span>` : ""],
|
|
69
|
+
["Unreadable", index.unreadable ? html`<span class="mono small">${plain(index.unreadable)}</span>` : ""],
|
|
70
|
+
])}
|
|
71
|
+
${proportionBar(index.byType ?? {})}
|
|
72
|
+
</div></section>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function render() {
|
|
76
|
+
const data = await api("/api/bundles");
|
|
77
|
+
return html`${pageHeader("Bundles", data.defaultBundle ? html`Default bundle <span class="mono">${data.defaultBundle}</span>${data.defaultWriteTarget ? html`, writes go to <span class="mono">${data.defaultWriteTarget}</span>` : ""}.` : "")}
|
|
78
|
+
<section><h2>Configured bundles</h2>${data.bundles.length === 0 ? emptyState("No bundles are configured.", "Run akm init, or add one with akm bundle add.") : html`<div class="cards">${data.bundles.map(card)}</div>`}</section>
|
|
79
|
+
${indexPanel(data.index)}`;
|
|
80
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Changes: recent commits of the write-target stash as a timeline grouped by day.
|
|
2
|
+
|
|
3
|
+
import { api, badge, emptyState, html, pageHeader } from "./lib.js";
|
|
4
|
+
|
|
5
|
+
const ORIGIN_KIND = { "improve-sync": "info", accept: "ok", remember: "accent", sync: "neutral", other: "warn" };
|
|
6
|
+
|
|
7
|
+
function commit(c) {
|
|
8
|
+
const more = c.pathCount > c.paths.length;
|
|
9
|
+
return html`<li class="commit">
|
|
10
|
+
<div class="commit-head"><span class="mono small time">${c.time.slice(11, 16)}</span>${badge(c.origin, ORIGIN_KIND[c.origin] ?? "neutral")}<span class="commit-subject">${c.subject}</span></div>
|
|
11
|
+
<div class="muted small"><span class="mono">${c.shortSha}</span> by ${c.author}</div>
|
|
12
|
+
${c.pathCount > 0 ? html`<details><summary>${c.pathCount} changed ${c.pathCount === 1 ? "path" : "paths"}</summary><ul class="plain mono small">${c.paths.map((p) => html`<li class="wrap">${p}</li>`)}${more ? html`<li class="muted">and ${c.pathCount - c.paths.length} more</li>` : ""}</ul></details>` : ""}
|
|
13
|
+
</li>`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function render() {
|
|
17
|
+
const data = await api("/api/changes", { limit: 100 });
|
|
18
|
+
const head = pageHeader(
|
|
19
|
+
"Changes",
|
|
20
|
+
html`Recent commits to <span class="mono">${data.bundle ?? "the stash"}</span>${data.path ? html` (<span class="mono wrap">${data.path}</span>)` : ""}.`,
|
|
21
|
+
);
|
|
22
|
+
if (!data.git) {
|
|
23
|
+
return html`${head}${emptyState("This stash is not a git repository.", "Changes are read from its git log, so there is no history to show.")}`;
|
|
24
|
+
}
|
|
25
|
+
if (data.commits.length === 0) return html`${head}${emptyState("No commits yet.")}`;
|
|
26
|
+
const days = new Map();
|
|
27
|
+
for (const c of data.commits) {
|
|
28
|
+
const day = c.time.slice(0, 10);
|
|
29
|
+
days.set(day, [...(days.get(day) ?? []), c]);
|
|
30
|
+
}
|
|
31
|
+
return html`${head}<div class="timeline">${[...days].map(([day, items]) => html`<section><h2 class="day">${day} <span class="muted num">${items.length}</span></h2><ol class="commits">${items.map(commit)}</ol></section>`)}</div>`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Inline SVG charts. Colours come from CSS classes (see styles.css) so they follow the light/dark theme.
|
|
2
|
+
|
|
3
|
+
import { html } from "./lib.js";
|
|
4
|
+
|
|
5
|
+
const round = (n) => Math.round(n * 10) / 10;
|
|
6
|
+
|
|
7
|
+
/** A "nice" axis top and tick list for counts: steps of 1, 2 or 5 times a power of ten, at most 5 intervals. */
|
|
8
|
+
export function niceScale(max) {
|
|
9
|
+
if (!(max > 0)) return { top: 1, ticks: [0, 1] };
|
|
10
|
+
let step = 1;
|
|
11
|
+
while (max / step > 5) step *= max / step > 25 ? 10 : max / step > 10 ? 5 : 2;
|
|
12
|
+
const top = Math.ceil(max / step) * step;
|
|
13
|
+
const ticks = [];
|
|
14
|
+
for (let v = 0; v <= top; v += step) ticks.push(v);
|
|
15
|
+
return { top, ticks };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const USAGE_SERIES = [
|
|
19
|
+
{ key: "search", label: "Search", cls: "s1" },
|
|
20
|
+
{ key: "curate", label: "Curate", cls: "s2" },
|
|
21
|
+
{ key: "show", label: "Show", cls: "s3" },
|
|
22
|
+
{ key: "feedback", label: "Feedback", cls: "s4" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Stacked daily bars on one scale. `days` is `usageByDay` from /api/overview. */
|
|
26
|
+
export function usageChart(days) {
|
|
27
|
+
const rows = days.map((d) => ({
|
|
28
|
+
date: d.date,
|
|
29
|
+
search: d.search ?? 0,
|
|
30
|
+
curate: d.curate ?? 0,
|
|
31
|
+
show: d.show ?? 0,
|
|
32
|
+
feedback: (d.feedbackPositive ?? 0) + (d.feedbackNegative ?? 0),
|
|
33
|
+
}));
|
|
34
|
+
const total = (r) => USAGE_SERIES.reduce((sum, s) => sum + r[s.key], 0);
|
|
35
|
+
const scale = niceScale(Math.max(0, ...rows.map(total)));
|
|
36
|
+
const W = 640;
|
|
37
|
+
const H = 260;
|
|
38
|
+
const left = 44;
|
|
39
|
+
const right = 8;
|
|
40
|
+
const top = 18;
|
|
41
|
+
const bottom = 34;
|
|
42
|
+
const plotW = W - left - right;
|
|
43
|
+
const plotH = H - top - bottom;
|
|
44
|
+
const slot = plotW / rows.length;
|
|
45
|
+
const barW = slot * 0.62;
|
|
46
|
+
const y = (v) => round(top + plotH - (v / scale.top) * plotH);
|
|
47
|
+
|
|
48
|
+
const grid = scale.ticks.map(
|
|
49
|
+
(v) =>
|
|
50
|
+
html`<line class="grid" x1="${left}" x2="${W - right}" y1="${y(v)}" y2="${y(v)}"/><text class="axis" x="${left - 6}" y="${y(v) + 4}" text-anchor="end">${v}</text>`,
|
|
51
|
+
);
|
|
52
|
+
const bars = rows.map((r, i) => {
|
|
53
|
+
const x = round(left + i * slot + (slot - barW) / 2);
|
|
54
|
+
let acc = 0;
|
|
55
|
+
const segments = USAGE_SERIES.map((s) => {
|
|
56
|
+
const v = r[s.key];
|
|
57
|
+
if (v === 0) return "";
|
|
58
|
+
const rect = html`<rect class="${s.cls}" x="${x}" y="${y(acc + v)}" width="${round(barW)}" height="${round(y(acc) - y(acc + v))}"/>`;
|
|
59
|
+
acc += v;
|
|
60
|
+
return rect;
|
|
61
|
+
});
|
|
62
|
+
const sum = total(r);
|
|
63
|
+
const tip = `${r.date}: ${USAGE_SERIES.map((s) => `${s.label.toLowerCase()} ${r[s.key]}`).join(", ")} (total ${sum})`;
|
|
64
|
+
return html`<g><title>${tip}</title>${segments}${sum > 0 ? html`<text class="axis total" x="${round(x + barW / 2)}" y="${y(sum) - 4}" text-anchor="middle">${sum}</text>` : ""}<text class="axis" x="${round(x + barW / 2)}" y="${H - bottom + 16}" text-anchor="middle">${r.date.slice(5)}</text></g>`;
|
|
65
|
+
});
|
|
66
|
+
const label = `Daily usage events for the last ${rows.length} days, stacked by search, curate, show and feedback`;
|
|
67
|
+
return html`<div class="chart-scroll"><svg class="chart" viewBox="0 0 ${W} ${H}" role="img" aria-label="${label}">${grid}${bars}<text class="axis" x="${left}" y="10">events per day (UTC)</text></svg></div>
|
|
68
|
+
<ul class="legend">${USAGE_SERIES.map((s) => html`<li><span class="swatch ${s.cls}"></span>${s.label}</li>`)}</ul>`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A small horizontal bar for a table cell: `value` of `max`, with the `failed` part in the failure colour. */
|
|
72
|
+
export function miniBar(value, max, failed = 0, label = "") {
|
|
73
|
+
const W = 96;
|
|
74
|
+
const w = max > 0 ? round((value / max) * W) : 0;
|
|
75
|
+
const fw = max > 0 ? round((failed / max) * W) : 0;
|
|
76
|
+
return html`<svg class="minibar" viewBox="0 0 ${W} 10" width="${W}" height="10" role="img" aria-label="${label}"><rect class="track" width="${W}" height="10" rx="2"/><rect class="fill" width="${w}" height="10" rx="2"/>${fw > 0 ? html`<rect class="fail" x="${round(w - fw)}" width="${fw}" height="10" rx="2"/>` : ""}</svg>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const PALETTE = ["s1", "s2", "s3", "s4", "s5", "s6"];
|
|
80
|
+
|
|
81
|
+
/** A stacked proportion bar (HTML, not SVG) with a legend listing each part's count. `counts` is { name: n }. */
|
|
82
|
+
export function proportionBar(counts) {
|
|
83
|
+
const entries = Object.entries(counts)
|
|
84
|
+
.filter(([, n]) => n > 0)
|
|
85
|
+
.sort((a, b) => b[1] - a[1]);
|
|
86
|
+
const total = entries.reduce((sum, [, n]) => sum + n, 0);
|
|
87
|
+
if (total === 0) return html`<p class="muted">No assets.</p>`;
|
|
88
|
+
return html`<div class="propbar" role="img" aria-label="${entries.map(([k, n]) => `${k} ${n}`).join(", ")}">${entries.map(
|
|
89
|
+
([k, n], i) =>
|
|
90
|
+
html`<span class="${PALETTE[i % PALETTE.length]}" style="width:${round((n / total) * 100)}%" title="${k}: ${n}"></span>`,
|
|
91
|
+
)}</div>
|
|
92
|
+
<ul class="legend">${entries.map(([k, n], i) => html`<li><span class="swatch ${PALETTE[i % PALETTE.length]}"></span>${k} <span class="num">${n}</span></li>`)}</ul>`;
|
|
93
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Config: the effective configuration as a collapsible tree with a source badge per key. Values arrive redacted.
|
|
2
|
+
|
|
3
|
+
import { api, badge, definitionList, emptyState, html, pageHeader, pretty, tableWrap } from "./lib.js";
|
|
4
|
+
|
|
5
|
+
function sourceBadge(source) {
|
|
6
|
+
if (source === "default") return badge("default", "neutral");
|
|
7
|
+
if (source === "file") return badge("file", "info");
|
|
8
|
+
if (typeof source === "string" && source.startsWith("extends:")) return badge(source, "accent");
|
|
9
|
+
return badge("untraced", "warn");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isObject(value) {
|
|
13
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function leafValue(value) {
|
|
17
|
+
if (value === "[redacted]") return html`<span class="redacted">[redacted]</span>`;
|
|
18
|
+
if (value === null) return html`<span class="muted">null</span>`;
|
|
19
|
+
if (typeof value === "string") return html`<span class="v-str">${value === "" ? '""' : value}</span>`;
|
|
20
|
+
if (typeof value === "number" || typeof value === "boolean") return html`<span class="v-lit">${String(value)}</span>`;
|
|
21
|
+
const text = JSON.stringify(value);
|
|
22
|
+
if (text.length <= 80) return html`<span class="mono">${text}</span>`;
|
|
23
|
+
const count = Array.isArray(value) ? `${value.length} items` : "object";
|
|
24
|
+
return html`<details class="inline"><summary>${count}</summary><pre class="code">${pretty(value)}</pre></details>`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function node(key, value, path, sources, depth) {
|
|
28
|
+
if (isObject(value)) {
|
|
29
|
+
const keys = Object.keys(value);
|
|
30
|
+
return html`<li><details ${depth < 1 ? html`open` : ""}><summary><span class="mono key">${key}</span> <span class="muted small">${keys.length} ${keys.length === 1 ? "key" : "keys"}</span></summary>
|
|
31
|
+
<ul class="tree">${keys.map((k) => node(k, value[k], path ? `${path}.${k}` : k, sources, depth + 1))}</ul></details></li>`;
|
|
32
|
+
}
|
|
33
|
+
const source = sources.has(path) ? sources.get(path) : null;
|
|
34
|
+
return html`<li class="leaf"><span class="mono key">${key}</span><span class="leaf-value">${leafValue(value)}</span>${sourceBadge(source)}</li>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function enginesTable(engines) {
|
|
38
|
+
if (engines.length === 0) return emptyState("No engines are configured.");
|
|
39
|
+
const rest = (e) => {
|
|
40
|
+
const { name, kind, endpoint, model, ...others } = e;
|
|
41
|
+
return Object.keys(others).length > 0 ? JSON.stringify(others) : "";
|
|
42
|
+
};
|
|
43
|
+
return tableWrap(
|
|
44
|
+
html`<table><thead><tr><th>Name</th><th>Kind</th><th>Endpoint</th><th>Model</th><th>Other</th></tr></thead><tbody>${engines.map(
|
|
45
|
+
(e) =>
|
|
46
|
+
html`<tr><td class="mono">${e.name}</td><td>${e.kind}</td><td class="mono wrap">${e.endpoint ?? ""}</td><td class="mono">${e.model ?? ""}</td><td class="mono small wrap">${rest(e)}</td></tr>`,
|
|
47
|
+
)}</tbody></table>`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function render() {
|
|
52
|
+
const data = await api("/api/config");
|
|
53
|
+
const sources = new Map(data.entries.map((e) => [e.path, e.source]));
|
|
54
|
+
const keys = Object.keys(data.config ?? {});
|
|
55
|
+
return html`${pageHeader("Config", "The effective configuration, and where each value comes from. Secret values are redacted by the server.")}
|
|
56
|
+
<section>${definitionList([
|
|
57
|
+
["Config file", html`<span class="mono">${data.path}</span> ${data.exists ? "" : badge("not found", "warn")}`],
|
|
58
|
+
])}${data.exists ? "" : html`<p class="muted">There is no config file, so everything below is a default.</p>`}</section>
|
|
59
|
+
<section><h2>Effective config</h2><p class="legend-line">Source: ${sourceBadge("default")} built-in ${sourceBadge("file")} this install's config.json ${sourceBadge("extends:ref")} a config it extends</p>
|
|
60
|
+
${keys.length === 0 ? emptyState("The effective config is empty.") : html`<ul class="tree root">${keys.map((k) => node(k, data.config[k], k, sources, 0))}</ul>`}</section>
|
|
61
|
+
<section><h2>Engines</h2>${enginesTable(data.engines)}</section>
|
|
62
|
+
<section><h2>Environment</h2><p class="muted">AKM_* variables set for the server. They override paths and credentials.</p>${
|
|
63
|
+
data.env.length === 0
|
|
64
|
+
? emptyState("No AKM_* environment variables are set.")
|
|
65
|
+
: tableWrap(
|
|
66
|
+
html`<table><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody>${data.env.map((e) => html`<tr><td class="mono">${e.name}</td><td class="mono wrap">${e.value === "[redacted]" ? html`<span class="redacted">[redacted]</span>` : e.value}</td></tr>`)}</tbody></table>`,
|
|
67
|
+
)
|
|
68
|
+
}</section>`;
|
|
69
|
+
}
|