akm-cli 0.10.261009-beta → 0.10.261010-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 +36 -0
- package/STABILITY.md +15 -4
- package/dist/assets/prompts/memory-infer-system.md +1 -1
- package/dist/assets/prompts/memory-infer-user.md +3 -2
- package/dist/assets/ui/app.js +59 -2
- package/dist/assets/ui/asset.js +71 -6
- package/dist/assets/ui/bundles.js +80 -3
- package/dist/assets/ui/diff-view.js +1 -1
- package/dist/assets/ui/editor.js +103 -0
- package/dist/assets/ui/index.html +2 -0
- package/dist/assets/ui/lib.js +51 -1
- package/dist/assets/ui/markdown.js +86 -19
- package/dist/assets/ui/search.js +25 -5
- package/dist/assets/ui/styles.css +70 -0
- package/dist/commands/health/improve-metrics.js +11 -1
- package/dist/commands/health/md-report.js +3 -3
- package/dist/commands/improve/loop-stages.js +2 -2
- package/dist/commands/read/curate.js +1 -1
- package/dist/commands/read/knowledge.js +109 -77
- package/dist/commands/sources/bundle-cli.js +4 -6
- package/dist/commands/sources/installed-stashes.js +10 -19
- package/dist/commands/ui/api.js +95 -5
- package/dist/commands/ui/document-source.js +51 -0
- package/dist/commands/ui/git-read.js +18 -2
- package/dist/commands/ui/proposal-actions.js +5 -3
- package/dist/commands/ui/server.js +25 -13
- package/dist/commands/ui/ui-cli.js +3 -3
- package/dist/commands/ui/workspace-actions.js +264 -0
- package/dist/commands/workflow-cli.js +3 -1
- package/dist/core/asset/akm-markdown.js +19 -3
- package/dist/core/asset/markdown.js +14 -6
- package/dist/core/config/config.js +6 -4
- package/dist/core/non-task-input.js +67 -3
- package/dist/core/write-source.js +2 -2
- package/dist/indexer/index-writer-lock.js +5 -1
- package/dist/indexer/passes/memory-inference.js +28 -6
- package/dist/llm/memory-infer.js +23 -14
- package/dist/scripts/akm-migrate-node.js +18 -10
- package/dist/scripts/akm-migrate.js +18 -10
- package/dist/workflows/exec/environment.js +4 -8
- package/dist/workflows/parser.js +13 -4
- package/docs/architecture/0.10-process-rules.md +5 -3
- package/docs/architecture/internals/storage-locations.md +1 -1
- package/docs/guides/author-workflows.md +78 -28
- package/docs/guides/bundles.md +16 -2
- package/docs/reference/cli.md +39 -16
- package/docs/reference/env-and-secrets.md +11 -0
- package/docs/reference/ui-api.md +84 -8
- package/docs/reference/workflow-schema.md +63 -32
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,42 @@ 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.261010] - 2026-10-21
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `akm ui` gains explicit write actions beside proposal decisions (#1217): edit
|
|
14
|
+
an indexed, writable knowledge or memory document's full Markdown source,
|
|
15
|
+
create knowledge, submit feedback, add, remove and update bundles, change
|
|
16
|
+
bundle settings, and view a document's Git file history. Each action reuses the
|
|
17
|
+
CLI operation and its validation, indexing and Git effects. A save is checked
|
|
18
|
+
against the source's raw-content revision and refused with `409
|
|
19
|
+
ASSET_CONTENT_CHANGED` if the file changed. Saves and creates answer `409
|
|
20
|
+
ASSET_MUTATION_LEASE_HELD` at once while another writer holds the lease, and
|
|
21
|
+
accept bodies up to 8 MiB (other writes stay at 64 KiB). Endpoints and status
|
|
22
|
+
codes are in `docs/reference/ui-api.md`.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- The shared Markdown writer behind `akm import` and `akm remember` now takes the
|
|
27
|
+
asset-mutation lease, so it waits for other akm writers instead of racing them
|
|
28
|
+
(#1217). It also accepts an optional expected content hash, which the UI's
|
|
29
|
+
editor uses to refuse a stale save. CLI writes without a hash behave as before.
|
|
30
|
+
|
|
31
|
+
- Curate withholds context for exact whole-message phrases such as `hello`,
|
|
32
|
+
`thanks, that worked!`, `ok sounds good, go ahead`, `yes`, and `lgtm` (#1201).
|
|
33
|
+
Matching normalizes case, whitespace and common punctuation; added task text
|
|
34
|
+
still retrieves. Use `akm search` for literal lookups that collide with the list.
|
|
35
|
+
- Memory inference accepts an explicit, cached "no change" decision (#1208). A
|
|
36
|
+
tool-count or checkpoint note, or a note already adequate as written, no longer
|
|
37
|
+
needs a derived memory or a model call on every run. The decision is cached on
|
|
38
|
+
the note's body hash (cache namespace `memory-inference-v3`; older rows are a
|
|
39
|
+
disposable miss), writes no child, and does not stamp the parent
|
|
40
|
+
`inferenceProcessed`, so an edited note is considered again. It is counted in `memoryInference.noChange` in the improve
|
|
41
|
+
result, progress line and health metrics (the health runs table gains a
|
|
42
|
+
`nochange` column), never in `skippedNoFacts`. Malformed or HTML replies,
|
|
43
|
+
timeouts, aborts and endpoint failures are still not cached and are retried.
|
|
44
|
+
|
|
9
45
|
## [0.10.261009] - 2026-10-21
|
|
10
46
|
|
|
11
47
|
### Changed
|
package/STABILITY.md
CHANGED
|
@@ -88,7 +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.
|
|
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. Browsing is read-only; explicit actions support proposals, feedback, bundle configuration and Markdown authoring. |
|
|
92
92
|
| `akm info` | Stable | |
|
|
93
93
|
| `akm bundle create` | Stable | |
|
|
94
94
|
| `akm bundle add` | Stable | |
|
|
@@ -401,14 +401,25 @@ for scripted use.
|
|
|
401
401
|
step that runs another workflow and returns its last step's output) are the
|
|
402
402
|
least settled part and are kept only in that simple form; `akm workflow list
|
|
403
403
|
--children` goes with them.
|
|
404
|
+
|
|
405
|
+
Per-key status today, from the Outcome section of
|
|
406
|
+
`docs/plans/0.10-workflows.md`. It stands until the promote-or-remove
|
|
407
|
+
decision in [#1096](https://github.com/itlackey/akm/issues/1096); every key
|
|
408
|
+
is still Experimental until then.
|
|
409
|
+
|
|
410
|
+
| Key | Status |
|
|
411
|
+
| --- | --- |
|
|
412
|
+
| `params`, `steps`, `inputs`, `output` / `unit.output`, `defaults.engine`, `### gate` | Core: the feature set pinned by `core-feature-set.test.ts` |
|
|
413
|
+
| exec units (`unit.exec`), `budget`, `unit.retry`, `map`, `gate.max_loops` | Kept in the 0.10 trim |
|
|
414
|
+
| child workflows (`unit.workflow`, `unit.with`) | Experimental: least settled |
|
|
404
415
|
- **`akm metrics`** — new in 0.9.28. What it reports, its JSON shape and the
|
|
405
416
|
`--format html` dashboard may change in any release; do not script against
|
|
406
417
|
them.
|
|
407
418
|
- **`akm ui`** — new in 0.10. A local web UI over the same data
|
|
408
419
|
`akm health`, `akm metrics`, `akm config`, `akm proposal`, `akm search` and
|
|
409
|
-
`akm show` report.
|
|
410
|
-
|
|
411
|
-
|
|
420
|
+
`akm show` report. Browsing is read-only. Explicit feedback, bundle/configuration,
|
|
421
|
+
knowledge-creation, revision-checked knowledge/memory editing and proposal-decision actions reuse existing AKM operations.
|
|
422
|
+
Historical restore remains unavailable. The command's flags, the page, and the `/api/` JSON the
|
|
412
423
|
page reads (documented in `docs/reference/ui-api.md`) may change in any
|
|
413
424
|
release; do not script against the API. The CLI's JSON output remains the
|
|
414
425
|
scriptable surface.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
You compress a developer memory into one high-signal derived memory for later retrieval. Return only valid JSON. No prose outside the JSON object. No markdown fences.
|
|
1
|
+
You compress a developer memory into one high-signal derived memory for later retrieval, or decide that it needs none. Return only valid JSON. No prose outside the JSON object. No markdown fences.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
Decide whether the memory below needs a derived memory, then output ONLY a JSON object with exactly these fields: noChange, title, description, tags, searchHints, and content.
|
|
2
|
+
No change: set noChange to true, title, description and content to empty strings, and tags and searchHints to empty arrays, only when a derived memory would add nothing for later retrieval: the memory is a tool-call count, a checkpoint or progress marker with no durable fact, or it is already a short, specific note that a rewrite would only restate. When in doubt, or when the memory contains any decision, cause, fix, version, path, config key or other fact worth finding later, do not use noChange.
|
|
3
|
+
Otherwise set noChange to false and compress the memory into one derived memory. Rules: title must be specific and short; description must be one sentence; tags must contain 3-8 specific tags; searchHints must contain 3-6 useful retrieval phrases; content must be a plain string with 2-3 sentences. Avoid vague generalizations, preserve key facts (names/versions/paths/dates/config keys verbatim), and merge related points. Never return field descriptions or placeholder values.
|
|
3
4
|
|
|
4
5
|
Memory:
|
package/dist/assets/ui/app.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// akm ui client: hash router and shell. Pages
|
|
1
|
+
// akm ui client: hash router and shell. Pages read the local JSON API and return markup
|
|
2
2
|
// built with the escaping `html` tag; this file only mounts it.
|
|
3
3
|
|
|
4
|
-
import { errorState, getMeta, html, initToken, loadingState, mount } from "./lib.js";
|
|
4
|
+
import { errorState, getMeta, hasUnsaved, html, initToken, loadingState, mount, setUnsaved, showFlash } from "./lib.js";
|
|
5
5
|
|
|
6
6
|
initToken();
|
|
7
7
|
|
|
@@ -18,10 +18,27 @@ const ROUTES = [
|
|
|
18
18
|
{ name: "changes", label: "Changes", load: () => import("./changes.js"), render: (m, c) => m.render(c) },
|
|
19
19
|
{ name: "search", label: "Search", load: () => import("./search.js"), render: (m, c) => m.render(c) },
|
|
20
20
|
{ name: "asset", label: "Asset", nav: "search", load: () => import("./asset.js"), render: (m, c) => m.render(c) },
|
|
21
|
+
{
|
|
22
|
+
name: "edit",
|
|
23
|
+
label: "Edit Markdown",
|
|
24
|
+
nav: "search",
|
|
25
|
+
load: () => import("./editor.js"),
|
|
26
|
+
render: (m, c) => m.render(c),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "new",
|
|
30
|
+
label: "New knowledge",
|
|
31
|
+
nav: "search",
|
|
32
|
+
load: () => import("./editor.js"),
|
|
33
|
+
render: (m, c) => m.render(c),
|
|
34
|
+
},
|
|
21
35
|
];
|
|
22
36
|
|
|
23
37
|
const main = document.getElementById("app");
|
|
24
38
|
let sequence = 0;
|
|
39
|
+
let shownHash = location.hash;
|
|
40
|
+
let pendingHash = null;
|
|
41
|
+
const unsaved = document.getElementById("unsaved");
|
|
25
42
|
|
|
26
43
|
function parseHash() {
|
|
27
44
|
const raw = location.hash.replace(/^#/, "") || "/overview";
|
|
@@ -44,6 +61,20 @@ function markNav(active) {
|
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
async function route() {
|
|
64
|
+
if (hasUnsaved()) {
|
|
65
|
+
// Keep the editor's address and ask inline; "Discard changes" repeats the navigation.
|
|
66
|
+
pendingHash = location.hash;
|
|
67
|
+
if (location.hash !== shownHash) history.replaceState(null, "", location.pathname + location.search + shownHash);
|
|
68
|
+
mount(
|
|
69
|
+
unsaved,
|
|
70
|
+
html`<p>You have unsaved changes.</p><p class="btn-row"><button type="button" class="btn-danger" data-act="discard">Discard changes</button><button type="button" class="btn-secondary" data-act="keep">Keep editing</button></p>`,
|
|
71
|
+
);
|
|
72
|
+
unsaved.hidden = false;
|
|
73
|
+
unsaved.querySelector("[data-act=keep]")?.focus();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
unsaved.hidden = true;
|
|
77
|
+
shownHash = location.hash;
|
|
47
78
|
const mine = ++sequence;
|
|
48
79
|
const { name, param, query } = parseHash();
|
|
49
80
|
const entry = ROUTES.find((r) => r.name === name);
|
|
@@ -64,6 +95,7 @@ async function route() {
|
|
|
64
95
|
if (mine !== sequence) return;
|
|
65
96
|
mount(main, markup);
|
|
66
97
|
for (const fn of ctx.after) fn(main);
|
|
98
|
+
showFlash(main);
|
|
67
99
|
window.scrollTo(0, 0);
|
|
68
100
|
}
|
|
69
101
|
} catch (error) {
|
|
@@ -87,5 +119,30 @@ async function showMeta() {
|
|
|
87
119
|
}
|
|
88
120
|
|
|
89
121
|
window.addEventListener("hashchange", route);
|
|
122
|
+
unsaved.addEventListener("click", (event) => {
|
|
123
|
+
const act = event.target.closest("button")?.dataset.act;
|
|
124
|
+
if (!act) return;
|
|
125
|
+
unsaved.hidden = true;
|
|
126
|
+
if (act !== "discard") return;
|
|
127
|
+
setUnsaved(false);
|
|
128
|
+
const target = pendingHash;
|
|
129
|
+
pendingHash = null;
|
|
130
|
+
// A same-hash target (Refresh view) fires no hashchange, so route directly.
|
|
131
|
+
if (target === null || target === location.hash) route();
|
|
132
|
+
else location.hash = target;
|
|
133
|
+
});
|
|
134
|
+
window.addEventListener("beforeunload", (event) => {
|
|
135
|
+
if (!hasUnsaved()) return;
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
event.returnValue = true; // Older browsers ask only when this is set.
|
|
138
|
+
});
|
|
139
|
+
document.addEventListener("keydown", (event) => {
|
|
140
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
|
141
|
+
event.preventDefault();
|
|
142
|
+
if (location.hash.startsWith("#/search")) document.querySelector('input[name="q"]')?.focus();
|
|
143
|
+
else location.hash = "#/search";
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
document.querySelector("[data-refresh]")?.addEventListener("click", route);
|
|
90
147
|
showMeta();
|
|
91
148
|
route();
|
package/dist/assets/ui/asset.js
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
// Asset view: metadata, frontmatter table, the body as safe markdown, links and feedback.
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { diffSection } from "./diff-view.js";
|
|
4
|
+
import {
|
|
5
|
+
actionForm,
|
|
6
|
+
api,
|
|
7
|
+
assetHref,
|
|
8
|
+
badge,
|
|
9
|
+
definitionList,
|
|
10
|
+
emptyState,
|
|
11
|
+
html,
|
|
12
|
+
pageHeader,
|
|
13
|
+
post,
|
|
14
|
+
tableWrap,
|
|
15
|
+
when,
|
|
16
|
+
} from "./lib.js";
|
|
17
|
+
import { headingId, markdown, splitFrontmatter } from "./markdown.js";
|
|
5
18
|
|
|
6
19
|
function linksBlock(links) {
|
|
7
20
|
const groups = Object.entries(links ?? {}).filter(([, kinds]) => kinds && typeof kinds === "object");
|
|
@@ -18,11 +31,22 @@ function linksBlock(links) {
|
|
|
18
31
|
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
32
|
}
|
|
20
33
|
|
|
21
|
-
function feedbackBlock(feedback) {
|
|
34
|
+
function feedbackBlock(feedback, ref, requireReason) {
|
|
22
35
|
const counts = feedback?.counts ?? { positive: 0, negative: 0 };
|
|
23
36
|
const history = feedback?.history ?? [];
|
|
24
37
|
return html`<section><h2>Feedback</h2>
|
|
25
38
|
<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>
|
|
39
|
+
${
|
|
40
|
+
ref
|
|
41
|
+
? html`<form class="feedback-form form-grid">
|
|
42
|
+
<label>Feedback <select name="signal"><option value="positive">Helpful</option><option value="negative">Incorrect or stale</option></select></label>
|
|
43
|
+
<label>Reason <textarea name="reason" rows="2" placeholder="What helped, or what is wrong and should change"></textarea></label>
|
|
44
|
+
<p class="muted small">${requireReason ? "A reason is required for negative feedback. " : ""}A note that did not fit your task is not necessarily incorrect.</p>
|
|
45
|
+
<label>Tags <input name="tags" placeholder="Optional key:value tags, comma-separated (team:platform)"></label>
|
|
46
|
+
<button type="submit">Send feedback</button><div data-status></div>
|
|
47
|
+
</form>`
|
|
48
|
+
: ""
|
|
49
|
+
}
|
|
26
50
|
${
|
|
27
51
|
history.length === 0
|
|
28
52
|
? emptyState("No feedback recorded for this asset.")
|
|
@@ -47,8 +71,46 @@ export async function render(ctx) {
|
|
|
47
71
|
<div class="notice">${data.note}</div>`;
|
|
48
72
|
}
|
|
49
73
|
const asset = data.asset;
|
|
50
|
-
const { fields, body } = splitFrontmatter(asset.content);
|
|
74
|
+
const { fields, body, startLine } = splitFrontmatter(asset.content);
|
|
75
|
+
const canonical = data.durableRef ?? asset.ref;
|
|
76
|
+
const headings = (data.headings ?? []).filter((h) => h.slug);
|
|
77
|
+
const historyOpen = ctx.query.get("history") === "1";
|
|
78
|
+
let history = "";
|
|
79
|
+
if (historyOpen) {
|
|
80
|
+
const versions = await api("/api/asset/history", { ref: canonical, revision: ctx.query.get("revision") });
|
|
81
|
+
const diff = versions.diff ? diffSection(versions.diff) : null;
|
|
82
|
+
if (diff?.wire) ctx.after.push(diff.wire);
|
|
83
|
+
history = html`<section><h2>Document history</h2>${
|
|
84
|
+
!versions.available
|
|
85
|
+
? emptyState("No Git history is available for this bundle.")
|
|
86
|
+
: html`
|
|
87
|
+
${versions.commits.length === 0 ? emptyState("No committed version of this file.") : html`<ul>${versions.commits.map((c) => html`<li><a href="${assetHref(canonical)}&history=1&revision=${c.sha}">${c.shortSha}</a> ${c.subject} <span class="muted">${when(c.time)} · ${c.author}</span></li>`)}</ul>`}
|
|
88
|
+
${diff?.markup ?? ""}`
|
|
89
|
+
}</section>`;
|
|
90
|
+
}
|
|
91
|
+
ctx.after.push((root) => {
|
|
92
|
+
actionForm(root, ".feedback-form", (_form, values) =>
|
|
93
|
+
post("/api/feedback", {
|
|
94
|
+
ref: canonical,
|
|
95
|
+
signal: values.get("signal"),
|
|
96
|
+
reason: values.get("reason"),
|
|
97
|
+
tags: String(values.get("tags") ?? "")
|
|
98
|
+
.split(",")
|
|
99
|
+
.map((t) => t.trim())
|
|
100
|
+
.filter(Boolean),
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
const signal = root.querySelector('[name="signal"]');
|
|
104
|
+
const reason = root.querySelector('[name="reason"]');
|
|
105
|
+
signal?.addEventListener("change", () => {
|
|
106
|
+
reason.required = data.feedbackRequireReason && signal.value === "negative";
|
|
107
|
+
});
|
|
108
|
+
const section = ctx.query.get("section");
|
|
109
|
+
// ?section= keeps the plain slug; the heading's element id is prefixed.
|
|
110
|
+
if (section) requestAnimationFrame(() => document.getElementById(headingId(section))?.scrollIntoView());
|
|
111
|
+
});
|
|
51
112
|
return html`${back}${pageHeader(html`<span class="mono wrap">${asset.ref}</span>`)}
|
|
113
|
+
<p class="action-links"><a href="${assetHref(canonical)}&history=1">Document history</a> · <a href="#/new">New knowledge</a>${asset.editable && ["knowledge", "memory"].includes(asset.type) ? html` · <a href="#/edit?ref=${encodeURIComponent(canonical)}">Edit Markdown</a>` : ""}</p>
|
|
52
114
|
<div class="badges">${badge(asset.type, "info")}${asset.origin ? badge(asset.origin, "neutral") : ""}${asset.editable ? badge("editable", "ok") : badge("read-only", "neutral")}</div>
|
|
53
115
|
<section>${definitionList([
|
|
54
116
|
["Name", html`<span class="mono">${asset.name}</span>`],
|
|
@@ -61,7 +123,10 @@ export async function render(ctx) {
|
|
|
61
123
|
["Path", html`<span class="mono wrap">${asset.path}</span>`],
|
|
62
124
|
])}</section>
|
|
63
125
|
${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
|
-
|
|
126
|
+
${headings.length > 1 ? html`<details class="contents" open><summary>On this page</summary><ul>${headings.map((h) => html`<li><a href="${assetHref(canonical)}§ion=${encodeURIComponent(h.slug)}">${h.text}</a></li>`)}</ul></details>` : ""}
|
|
127
|
+
<section><h2>Content</h2>${body.trim() === "" ? emptyState("This asset has no body.") : markdown(body, { ref: canonical, bundle: data.bundle, links: data.links, headings: data.headings, startLine })}</section>
|
|
128
|
+
<details><summary>Markdown source</summary><pre class="code">${asset.content ?? ""}</pre></details>
|
|
129
|
+
${history}
|
|
65
130
|
${linksBlock(asset.links)}
|
|
66
|
-
${feedbackBlock(data.feedback)}`;
|
|
131
|
+
${feedbackBlock(data.feedback, data.durableRef, data.feedbackRequireReason)}`;
|
|
67
132
|
}
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
// Bundles: one card per configured bundle with git status and asset counts, plus the index state.
|
|
2
2
|
|
|
3
3
|
import { proportionBar } from "./charts.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
actionForm,
|
|
6
|
+
api,
|
|
7
|
+
badge,
|
|
8
|
+
definitionList,
|
|
9
|
+
emptyState,
|
|
10
|
+
fmtNum,
|
|
11
|
+
fmtPct,
|
|
12
|
+
html,
|
|
13
|
+
pageHeader,
|
|
14
|
+
post,
|
|
15
|
+
when,
|
|
16
|
+
} from "./lib.js";
|
|
5
17
|
|
|
6
18
|
function gitBlock(git) {
|
|
7
19
|
if (!git) return html`<p class="muted">Not a git repository.</p>`;
|
|
@@ -29,8 +41,10 @@ function gitBlock(git) {
|
|
|
29
41
|
}
|
|
30
42
|
|
|
31
43
|
function card(bundle) {
|
|
44
|
+
const writableKind = ["filesystem", "git"].includes(bundle.kind);
|
|
32
45
|
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>
|
|
46
|
+
<header class="card-head"><h3>${bundle.name}</h3>${badge(bundle.kind, "info")}${bundle.default ? badge("default", "neutral") : ""}${bundle.enabled ? "" : badge("disabled", "warn")}${bundle.writable ? badge("writable", "ok") : badge("read-only", "neutral")}${bundle.exists ? "" : badge("missing", "fail")}</header>
|
|
47
|
+
<p><a href="#/search?bundle=${encodeURIComponent(bundle.name)}">Browse assets</a></p>
|
|
34
48
|
${definitionList([
|
|
35
49
|
["Path", bundle.path ? html`<span class="mono">${bundle.path}</span>` : html`<span class="muted">none</span>`],
|
|
36
50
|
[
|
|
@@ -44,6 +58,21 @@ function card(bundle) {
|
|
|
44
58
|
])}
|
|
45
59
|
${proportionBar(bundle.byType ?? {})}
|
|
46
60
|
<h4>Git</h4>${gitBlock(bundle.git)}
|
|
61
|
+
<details><summary>Bundle settings</summary>
|
|
62
|
+
<form class="bundle-settings form-grid" data-bundle="${bundle.name}">
|
|
63
|
+
<p class="muted small">Settings from ${bundle.settingsSource ?? "configuration"}. Saving creates a local override. Component settings take precedence over the bundle writable default. Choose another default before disabling the default bundle.</p>
|
|
64
|
+
<label class="check"><input type="checkbox" name="enabled" data-initial="${bundle.enabled}" ${bundle.enabled ? html`checked` : ""}> Enabled</label>
|
|
65
|
+
<label class="check"><input type="checkbox" name="writable" data-initial="${bundle.writableSetting ?? bundle.kind === "filesystem"}" ${(bundle.writableSetting ?? bundle.kind === "filesystem") ? html`checked` : ""} ${writableKind ? "" : html`disabled`}> Bundle writable default</label>
|
|
66
|
+
${(bundle.components ?? []).map((c) => html`<label class="check"><input type="checkbox" name="component:${c.name}" data-initial="${c.writable ?? bundle.writableSetting ?? bundle.kind === "filesystem"}" ${(c.writable ?? bundle.writableSetting ?? bundle.kind === "filesystem") ? html`checked` : ""} ${writableKind && c.configured && !c.name.includes(".") ? "" : html`disabled`}> ${c.name} writable <span class="muted small">(${c.adapter}, ${c.root ?? "."}${c.writable === undefined ? ", inherits default" : ", explicit override"})</span></label>`)}
|
|
67
|
+
<button type="submit">Save settings</button><div data-status></div>
|
|
68
|
+
</form>
|
|
69
|
+
</details>
|
|
70
|
+
<form class="bundle-update" data-bundle="${bundle.name}"><p><button type="submit" class="btn-secondary">Update / reindex bundle</button></p><div data-status></div></form>
|
|
71
|
+
<details><summary>Remove bundle</summary><form class="bundle-remove form-grid" data-bundle="${bundle.name}">
|
|
72
|
+
<p>Uses <code>akm bundle remove</code>. Managed installed content may be deleted; filesystem sources remain on disk.</p>
|
|
73
|
+
<label class="check"><input type="checkbox" name="confirm" required> Remove ${bundle.name}</label>
|
|
74
|
+
<button type="submit" class="btn-danger">Remove bundle</button><div data-status></div>
|
|
75
|
+
</form></details>
|
|
47
76
|
</article>`;
|
|
48
77
|
}
|
|
49
78
|
|
|
@@ -72,9 +101,57 @@ function indexPanel(index) {
|
|
|
72
101
|
</div></section>`;
|
|
73
102
|
}
|
|
74
103
|
|
|
75
|
-
export async function render() {
|
|
104
|
+
export async function render(ctx) {
|
|
76
105
|
const data = await api("/api/bundles");
|
|
106
|
+
ctx.after.push((root) => {
|
|
107
|
+
actionForm(root, ".bundle-settings", (form, values) =>
|
|
108
|
+
post("/api/bundles/settings", {
|
|
109
|
+
bundle: form.dataset.bundle,
|
|
110
|
+
...Object.fromEntries(
|
|
111
|
+
["enabled", "writable"]
|
|
112
|
+
.filter(
|
|
113
|
+
(key) =>
|
|
114
|
+
!form.elements[key].disabled &&
|
|
115
|
+
form.elements[key].checked !== (form.elements[key].dataset.initial === "true"),
|
|
116
|
+
)
|
|
117
|
+
.map((key) => [key, values.has(key)]),
|
|
118
|
+
),
|
|
119
|
+
componentWritability: Object.fromEntries(
|
|
120
|
+
[...form.querySelectorAll('input[name^="component:"]:not(:disabled)')]
|
|
121
|
+
.filter((input) => input.checked !== (input.dataset.initial === "true"))
|
|
122
|
+
.map((input) => [input.name.slice(10), input.checked]),
|
|
123
|
+
),
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
actionForm(root, ".bundle-defaults", (form, values) =>
|
|
127
|
+
post(
|
|
128
|
+
"/api/bundles/settings",
|
|
129
|
+
Object.fromEntries([...values].filter(([key, value]) => value !== form.elements[key].dataset.initial)),
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
actionForm(root, ".bundle-add", (_form, values) =>
|
|
133
|
+
post("/api/bundles/add", { ...Object.fromEntries(values), writable: values.has("writable") }),
|
|
134
|
+
);
|
|
135
|
+
actionForm(root, ".bundle-update", (form) => post("/api/bundles/update", { bundle: form.dataset.bundle }));
|
|
136
|
+
actionForm(root, ".bundle-remove", (form, values) =>
|
|
137
|
+
post("/api/bundles/remove", { bundle: form.dataset.bundle, confirm: values.has("confirm") }),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
const choices = (selected) =>
|
|
141
|
+
html`<option value="">Use fallback</option>${data.bundles.map((b) => html`<option value="${b.name}" ${b.name === selected ? html`selected` : ""}>${b.name}${b.enabled ? "" : " (disabled)"}</option>`)}`;
|
|
77
142
|
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>` : ""}.` : "")}
|
|
143
|
+
<section><h2>Defaults</h2><form class="bundle-defaults form-grid card">
|
|
144
|
+
<label>Default bundle<select name="defaultBundle" data-initial="${data.defaultBundle ?? ""}">${choices(data.defaultBundle)}</select></label>
|
|
145
|
+
<label>Default write target<select name="defaultWriteTarget" data-initial="${data.defaultWriteTarget ?? ""}">${choices(data.defaultWriteTarget)}</select></label>
|
|
146
|
+
<button type="submit">Save defaults</button><div data-status></div>
|
|
147
|
+
</form></section>
|
|
78
148
|
<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>
|
|
149
|
+
<section><h2>Add a bundle</h2><form class="bundle-add form-grid card">
|
|
150
|
+
<label>Source<input name="source" required placeholder="Local path, Git URL, npm package, or website URL"></label>
|
|
151
|
+
<label>Name<input name="name" placeholder="Optional bundle name"></label>
|
|
152
|
+
<label class="check"><input type="checkbox" name="writable"> Allow writes to a Git source (local directories are writable by default)</label>
|
|
153
|
+
<p class="muted small">Uses the existing source detection, validation and installation audit. Adding and updating sources can access the network.</p>
|
|
154
|
+
<button type="submit">Add bundle</button><div data-status></div>
|
|
155
|
+
</form></section>
|
|
79
156
|
${indexPanel(data.index)}`;
|
|
80
157
|
}
|
|
@@ -137,7 +137,7 @@ function toolbar(mode, model) {
|
|
|
137
137
|
|
|
138
138
|
function aligned(model, before, after, state) {
|
|
139
139
|
if (model.same) {
|
|
140
|
-
return html`<p class="muted">No line differs between
|
|
140
|
+
return html`<p class="muted">No line differs between these versions${before === after ? "" : ", only line endings or the final newline"}.</p>`;
|
|
141
141
|
}
|
|
142
142
|
const body =
|
|
143
143
|
state.mode === "inline"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Source-first creation and revision-checked editing use the existing Markdown writer.
|
|
2
|
+
import { actionForm, api, assetHref, emptyState, html, mount, pageHeader, post, setUnsaved } from "./lib.js";
|
|
3
|
+
import { markdown, splitFrontmatter } from "./markdown.js";
|
|
4
|
+
|
|
5
|
+
export async function render(ctx) {
|
|
6
|
+
const ref = ctx.query.get("ref");
|
|
7
|
+
if (ref) return edit(ctx, ref);
|
|
8
|
+
const data = await api("/api/bundles");
|
|
9
|
+
const writable = data.bundles.filter((b) => b.enabled && b.writable && b.components.some((c) => c.adapter === "akm"));
|
|
10
|
+
ctx.after.push((root) => {
|
|
11
|
+
actionForm(root, ".create-form", (_form, values) => post("/api/knowledge/create", Object.fromEntries(values)), {
|
|
12
|
+
after: (result) => {
|
|
13
|
+
setUnsaved(false);
|
|
14
|
+
if (result.ref) location.hash = assetHref(result.ref);
|
|
15
|
+
else location.hash = "#/search";
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
const form = root.querySelector(".create-form");
|
|
19
|
+
if (form) {
|
|
20
|
+
const draw = () =>
|
|
21
|
+
mount(root.querySelector("[data-preview]"), markdown(splitFrontmatter(form.elements.content.value).body));
|
|
22
|
+
form.elements.content.addEventListener("input", draw);
|
|
23
|
+
form.addEventListener("input", () => setUnsaved(true));
|
|
24
|
+
draw();
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
return html`${pageHeader("New knowledge", "Write Markdown and optional frontmatter. Existing files are never overwritten.")}
|
|
28
|
+
<p><a href="#/search">Back to search</a></p>
|
|
29
|
+
${
|
|
30
|
+
writable.length === 0
|
|
31
|
+
? emptyState(
|
|
32
|
+
"No enabled writable AKM bundle is available.",
|
|
33
|
+
html`Check <a href="#/bundles">bundle settings</a>.`,
|
|
34
|
+
)
|
|
35
|
+
: html`
|
|
36
|
+
<form class="create-form form-grid">
|
|
37
|
+
<label>Bundle<select name="bundle">${writable.map((b) => html`<option value="${b.name}" ${b.name === (data.defaultWriteTarget ?? data.defaultBundle) ? html`selected` : ""}>${b.name}</option>`)}</select></label>
|
|
38
|
+
<label>Name<input name="name" required placeholder="restore-database"></label>
|
|
39
|
+
<label>Subdirectory<input name="path" placeholder="Optional, under knowledge/"></label>
|
|
40
|
+
<label>Markdown<textarea name="content" rows="16" required spellcheck="false"># New document
|
|
41
|
+
|
|
42
|
+
</textarea></label>
|
|
43
|
+
<button type="submit">Create knowledge</button><div data-status></div>
|
|
44
|
+
</form>
|
|
45
|
+
<section><h2>Preview</h2><div data-preview></div></section>`
|
|
46
|
+
}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A textarea's value has every line break as LF.
|
|
50
|
+
const asTextarea = (content) => content.replace(/\r\n?/g, "\n");
|
|
51
|
+
|
|
52
|
+
/** The save request for `draft`: an untouched draft sends the exact source; a uniform-CRLF source keeps CRLF. */
|
|
53
|
+
function saveBody(source, draft) {
|
|
54
|
+
const usesCrlf = source.content.includes("\r\n") && !/[\r\n]/.test(source.content.replace(/\r\n/g, ""));
|
|
55
|
+
const content =
|
|
56
|
+
draft === asTextarea(source.content) ? source.content : usesCrlf ? draft.replace(/\r?\n/g, "\r\n") : draft;
|
|
57
|
+
return { ref: source.ref, revision: source.revision, content };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function edit(ctx, ref) {
|
|
61
|
+
let source = await api("/api/asset/source", { ref });
|
|
62
|
+
const context = { ref: source.ref, bundle: source.ref.split("//")[0] };
|
|
63
|
+
ctx.after.push((root) => {
|
|
64
|
+
const input = root.querySelector('[name="content"]');
|
|
65
|
+
// HTML textarea parsing drops an initial LF, so initialize explicitly.
|
|
66
|
+
input.value = source.content;
|
|
67
|
+
actionForm(
|
|
68
|
+
root,
|
|
69
|
+
".edit-form",
|
|
70
|
+
async () => {
|
|
71
|
+
try {
|
|
72
|
+
return await post("/api/asset/save", saveBody(source, input.value));
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error?.code !== "ASSET_CONTENT_CHANGED") throw error;
|
|
75
|
+
// Take the new revision in place; the draft in the textarea stays as typed.
|
|
76
|
+
source = await api("/api/asset/source", { ref: source.ref });
|
|
77
|
+
throw new Error(
|
|
78
|
+
"The document changed on disk since you opened it. Your draft was kept; saving again will overwrite the newer version.",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
after: () => {
|
|
84
|
+
setUnsaved(false);
|
|
85
|
+
location.hash = assetHref(source.ref);
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
const draw = () =>
|
|
90
|
+
mount(root.querySelector("[data-preview]"), markdown(splitFrontmatter(input.value).body, context));
|
|
91
|
+
input.addEventListener("input", () => {
|
|
92
|
+
setUnsaved(input.value !== asTextarea(source.content));
|
|
93
|
+
draw();
|
|
94
|
+
});
|
|
95
|
+
draw();
|
|
96
|
+
});
|
|
97
|
+
return html`${pageHeader("Edit Markdown", source.ref)}
|
|
98
|
+
<p><a href="${assetHref(source.ref)}">Back to document</a></p>
|
|
99
|
+
<p class="muted">Edit the full ${source.type} source, including frontmatter. If the file changes on disk meanwhile, the first save is refused and your draft is kept; saving again overwrites the newer version.</p>
|
|
100
|
+
<form class="edit-form form-grid"><label>Markdown<textarea name="content" rows="20" spellcheck="false">${source.content}</textarea></label>
|
|
101
|
+
<button type="submit">Save Markdown</button><div data-status></div>
|
|
102
|
+
</form><section><h2>Preview</h2><div data-preview></div></section>`;
|
|
103
|
+
}
|
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
<li><a data-route="changes" href="#/changes">Changes</a></li>
|
|
21
21
|
<li><a data-route="search" href="#/search">Search</a></li>
|
|
22
22
|
</ul>
|
|
23
|
+
<button type="button" data-refresh>Refresh view</button>
|
|
23
24
|
<div class="nav-meta" id="nav-meta"></div>
|
|
24
25
|
</nav>
|
|
26
|
+
<div id="unsaved" class="notice notice-warn" role="alert" hidden></div>
|
|
25
27
|
<main id="app" aria-live="polite">
|
|
26
28
|
<noscript><p>akm ui needs JavaScript. It makes no network requests beyond its own local server.</p></noscript>
|
|
27
29
|
</main>
|
package/dist/assets/ui/lib.js
CHANGED
|
@@ -102,7 +102,7 @@ export function api(path, query = {}) {
|
|
|
102
102
|
return request(url, { headers: { "X-Akm-Ui-Token": token ?? "" }, cache: "no-store" });
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
/** POST `body` as JSON to
|
|
105
|
+
/** POST `body` as JSON to an explicit action. The token goes in the header, never the URL. */
|
|
106
106
|
export function post(path, body = {}) {
|
|
107
107
|
return request(path, {
|
|
108
108
|
method: "POST",
|
|
@@ -236,3 +236,53 @@ export function pretty(value) {
|
|
|
236
236
|
export function percent(part, total) {
|
|
237
237
|
return total > 0 ? Math.round((part / total) * 1000) / 10 : 0;
|
|
238
238
|
}
|
|
239
|
+
|
|
240
|
+
// ---- unsaved drafts and one-shot status ------------------------------------
|
|
241
|
+
|
|
242
|
+
let unsaved = false;
|
|
243
|
+
/** The editor marks a draft that leaving the page would lose; the router asks before discarding it. */
|
|
244
|
+
export function setUnsaved(value) {
|
|
245
|
+
unsaved = value;
|
|
246
|
+
}
|
|
247
|
+
export function hasUnsaved() {
|
|
248
|
+
return unsaved;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let flash = null;
|
|
252
|
+
/** Show the status a form left before its page re-rendered, once. Called by the router after each render. */
|
|
253
|
+
export function showFlash(root) {
|
|
254
|
+
const pending = flash;
|
|
255
|
+
flash = null;
|
|
256
|
+
const status = pending && root.querySelectorAll(pending.selector)[pending.index]?.querySelector("[data-status]");
|
|
257
|
+
if (status) mount(status, html`<p role="status" class="text-ok">${pending.message}</p>`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Bind an explicit form action; errors stay beside the user's inputs. */
|
|
261
|
+
export function actionForm(root, selector, action, { after } = {}) {
|
|
262
|
+
root.querySelectorAll(selector).forEach((form, index) => {
|
|
263
|
+
form.addEventListener("submit", async (event) => {
|
|
264
|
+
event.preventDefault();
|
|
265
|
+
const status = form.querySelector("[data-status]");
|
|
266
|
+
const button = event.submitter;
|
|
267
|
+
let locked = false;
|
|
268
|
+
if (button) button.disabled = true;
|
|
269
|
+
if (status) mount(status, html`<p role="status">Working…</p>`);
|
|
270
|
+
try {
|
|
271
|
+
const result = await action(form, new FormData(form));
|
|
272
|
+
const message = result?.saved === false ? "No changes." : "Saved.";
|
|
273
|
+
if (status) mount(status, html`<p role="status" class="text-ok">${message}</p>`);
|
|
274
|
+
if (after) after(result);
|
|
275
|
+
else {
|
|
276
|
+
flash = { selector, index, message };
|
|
277
|
+
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
|
278
|
+
}
|
|
279
|
+
} catch (error) {
|
|
280
|
+
// The action may already have happened; the server asks not to repeat it without a reload.
|
|
281
|
+
locked = error?.code === "INVALID_COMMAND_RESPONSE";
|
|
282
|
+
if (status) mount(status, html`<p role="alert" class="text-fail">${error.message}</p>`);
|
|
283
|
+
} finally {
|
|
284
|
+
if (button && !locked) button.disabled = false;
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
}
|