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.
Files changed (50) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/STABILITY.md +15 -4
  3. package/dist/assets/prompts/memory-infer-system.md +1 -1
  4. package/dist/assets/prompts/memory-infer-user.md +3 -2
  5. package/dist/assets/ui/app.js +59 -2
  6. package/dist/assets/ui/asset.js +71 -6
  7. package/dist/assets/ui/bundles.js +80 -3
  8. package/dist/assets/ui/diff-view.js +1 -1
  9. package/dist/assets/ui/editor.js +103 -0
  10. package/dist/assets/ui/index.html +2 -0
  11. package/dist/assets/ui/lib.js +51 -1
  12. package/dist/assets/ui/markdown.js +86 -19
  13. package/dist/assets/ui/search.js +25 -5
  14. package/dist/assets/ui/styles.css +70 -0
  15. package/dist/commands/health/improve-metrics.js +11 -1
  16. package/dist/commands/health/md-report.js +3 -3
  17. package/dist/commands/improve/loop-stages.js +2 -2
  18. package/dist/commands/read/curate.js +1 -1
  19. package/dist/commands/read/knowledge.js +109 -77
  20. package/dist/commands/sources/bundle-cli.js +4 -6
  21. package/dist/commands/sources/installed-stashes.js +10 -19
  22. package/dist/commands/ui/api.js +95 -5
  23. package/dist/commands/ui/document-source.js +51 -0
  24. package/dist/commands/ui/git-read.js +18 -2
  25. package/dist/commands/ui/proposal-actions.js +5 -3
  26. package/dist/commands/ui/server.js +25 -13
  27. package/dist/commands/ui/ui-cli.js +3 -3
  28. package/dist/commands/ui/workspace-actions.js +264 -0
  29. package/dist/commands/workflow-cli.js +3 -1
  30. package/dist/core/asset/akm-markdown.js +19 -3
  31. package/dist/core/asset/markdown.js +14 -6
  32. package/dist/core/config/config.js +6 -4
  33. package/dist/core/non-task-input.js +67 -3
  34. package/dist/core/write-source.js +2 -2
  35. package/dist/indexer/index-writer-lock.js +5 -1
  36. package/dist/indexer/passes/memory-inference.js +28 -6
  37. package/dist/llm/memory-infer.js +23 -14
  38. package/dist/scripts/akm-migrate-node.js +18 -10
  39. package/dist/scripts/akm-migrate.js +18 -10
  40. package/dist/workflows/exec/environment.js +4 -8
  41. package/dist/workflows/parser.js +13 -4
  42. package/docs/architecture/0.10-process-rules.md +5 -3
  43. package/docs/architecture/internals/storage-locations.md +1 -1
  44. package/docs/guides/author-workflows.md +78 -28
  45. package/docs/guides/bundles.md +16 -2
  46. package/docs/reference/cli.md +39 -16
  47. package/docs/reference/env-and-secrets.md +11 -0
  48. package/docs/reference/ui-api.md +84 -8
  49. package/docs/reference/workflow-schema.md +63 -32
  50. 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. Read-only except that proposals can be accepted (singly or in a batch) and rejected from it. |
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. 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
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
- Compress the memory below into one derived memory. Output ONLY a JSON object with exactly these fields: title, description, tags, searchHints, and content.
2
- 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/config keys verbatim), and merge related points. Never return field descriptions or placeholder values.
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:
@@ -1,7 +1,7 @@
1
- // akm ui client: hash router and shell. Pages are modules that fetch from the read-only JSON API and return markup
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();
@@ -1,7 +1,20 @@
1
1
  // Asset view: metadata, frontmatter table, the body as safe markdown, links and feedback.
2
2
 
3
- import { api, assetHref, badge, definitionList, emptyState, html, pageHeader, tableWrap, when } from "./lib.js";
4
- import { markdown, splitFrontmatter } from "./markdown.js";
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> &nbsp; <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
- <section><h2>Content</h2>${body.trim() === "" ? emptyState("This asset has no body.") : markdown(body)}</section>
126
+ ${headings.length > 1 ? html`<details class="contents" open><summary>On this page</summary><ul>${headings.map((h) => html`<li><a href="${assetHref(canonical)}&section=${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 { api, badge, definitionList, emptyState, fmtNum, fmtPct, html, pageHeader, when } from "./lib.js";
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 the current asset and the proposal${before === after ? "" : ", only line endings or the final newline"}.</p>`;
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>
@@ -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 `path`. Only the proposal accept/reject routes take one; the token goes in the header, never the URL. */
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
+ }