akm-cli 0.10.261008-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 (82) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/STABILITY.md +25 -8
  3. package/dist/api.d.ts +6 -0
  4. package/dist/api.js +12 -1
  5. package/dist/assets/prompts/consolidate-pair-check.md +2 -0
  6. package/dist/assets/prompts/consolidate-pair-same-age.md +1 -0
  7. package/dist/assets/prompts/consolidate-pair-terms.md +6 -0
  8. package/dist/assets/prompts/consolidate-pair.md +4 -12
  9. package/dist/assets/prompts/memory-infer-system.md +1 -1
  10. package/dist/assets/prompts/memory-infer-user.md +3 -2
  11. package/dist/assets/ui/app.js +59 -2
  12. package/dist/assets/ui/asset.js +71 -6
  13. package/dist/assets/ui/bundles.js +80 -3
  14. package/dist/assets/ui/diff-view.js +1 -1
  15. package/dist/assets/ui/editor.js +103 -0
  16. package/dist/assets/ui/index.html +2 -0
  17. package/dist/assets/ui/lib.js +51 -1
  18. package/dist/assets/ui/markdown.js +86 -19
  19. package/dist/assets/ui/search.js +25 -5
  20. package/dist/assets/ui/styles.css +70 -0
  21. package/dist/commands/health/improve-metrics.js +11 -1
  22. package/dist/commands/health/md-report.js +3 -3
  23. package/dist/commands/health.js +1 -2
  24. package/dist/commands/improve/consolidate/pair-pass.js +59 -31
  25. package/dist/commands/improve/extract.js +18 -12
  26. package/dist/commands/improve/loop-stages.js +2 -2
  27. package/dist/commands/improve/memory/memory-belief.js +2 -2
  28. package/dist/commands/proposal/repository.js +44 -13
  29. package/dist/commands/read/curate.js +78 -19
  30. package/dist/commands/read/knowledge.js +109 -77
  31. package/dist/commands/read/search-cli.js +25 -11
  32. package/dist/commands/read/search.js +3 -3
  33. package/dist/commands/read/show.js +47 -1
  34. package/dist/commands/sources/bundle-cli.js +4 -6
  35. package/dist/commands/sources/installed-stashes.js +10 -19
  36. package/dist/commands/ui/api.js +95 -5
  37. package/dist/commands/ui/document-source.js +51 -0
  38. package/dist/commands/ui/git-read.js +18 -2
  39. package/dist/commands/ui/proposal-actions.js +5 -3
  40. package/dist/commands/ui/server.js +25 -13
  41. package/dist/commands/ui/ui-cli.js +3 -3
  42. package/dist/commands/ui/workspace-actions.js +264 -0
  43. package/dist/commands/workflow-cli.js +3 -1
  44. package/dist/core/adapter/adapters/akm-adapter.js +1 -0
  45. package/dist/core/adapter/adapters/llm-wiki-adapter.js +3 -5
  46. package/dist/core/adapter/adapters/okf-adapter.js +3 -16
  47. package/dist/core/asset/akm-markdown.js +19 -3
  48. package/dist/core/asset/markdown-link-targets.js +43 -0
  49. package/dist/core/asset/markdown.js +14 -6
  50. package/dist/core/config/config.js +6 -4
  51. package/dist/core/non-task-input.js +67 -3
  52. package/dist/core/write-source.js +2 -2
  53. package/dist/indexer/index-writer-lock.js +5 -1
  54. package/dist/indexer/passes/memory-inference.js +28 -6
  55. package/dist/indexer/passes/metadata.js +17 -0
  56. package/dist/indexer/scan/doc-to-entry.js +1 -0
  57. package/dist/indexer/search/db-search.js +16 -7
  58. package/dist/indexer/search/search-hit-enrichers.js +3 -3
  59. package/dist/llm/memory-infer.js +23 -14
  60. package/dist/output/shapes/curate.js +3 -1
  61. package/dist/output/shapes/helpers.js +6 -0
  62. package/dist/output/text/show-format.js +7 -0
  63. package/dist/scripts/akm-migrate-node.js +1820 -329
  64. package/dist/scripts/akm-migrate.js +1820 -329
  65. package/dist/storage/repositories/index-entries-repository.js +16 -0
  66. package/dist/storage/repositories/index-fts-repository.js +28 -6
  67. package/dist/storage/repositories/index-vec-repository.js +19 -3
  68. package/dist/workflows/exec/environment.js +4 -8
  69. package/dist/workflows/parser.js +13 -4
  70. package/docs/architecture/0.10-process-rules.md +9 -7
  71. package/docs/architecture/architecture.md +4 -0
  72. package/docs/architecture/internals/storage-locations.md +1 -1
  73. package/docs/architecture/persisted-data-compat.md +1 -1
  74. package/docs/guides/author-workflows.md +78 -28
  75. package/docs/guides/bundles.md +16 -2
  76. package/docs/reference/api.md +19 -1
  77. package/docs/reference/cli.md +90 -18
  78. package/docs/reference/env-and-secrets.md +11 -0
  79. package/docs/reference/memory.md +7 -6
  80. package/docs/reference/ui-api.md +84 -8
  81. package/docs/reference/workflow-schema.md +63 -32
  82. package/package.json +2 -1
@@ -2,8 +2,8 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
- * The read-only JSON API behind `akm ui`. (The three proposal writes live in
6
- * `proposal-actions.ts`; nothing in this file writes.) Every handler calls the function the
5
+ * The read-only JSON API behind `akm ui`. Explicit writes live in
6
+ * `proposal-actions.ts` and `workspace-actions.ts`; nothing in this file writes. Every handler calls the function the
7
7
  * matching CLI command renders (`akmHealth`, `akmMetrics`, `akmSearch`,
8
8
  * `akmShowUnified`, the proposal repository, ...), so the UI and the CLI never
9
9
  * disagree and there is no second data layer. Documented in
@@ -14,8 +14,11 @@
14
14
  * `readOnly` (no usage events, no index build), and git is only queried.
15
15
  */
16
16
  import fs from "node:fs";
17
+ import path from "node:path";
17
18
  import { parsePositiveIntFlag } from "../../cli/parse-args.js";
18
19
  import { parseBundleRef } from "../../core/asset/asset-ref.js";
20
+ import { parseMarkdownToc, uniqueHeadingSlugs } from "../../core/asset/markdown.js";
21
+ import { markdownLinkTargets } from "../../core/asset/markdown-link-targets.js";
19
22
  import { typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
20
23
  import { resolveStashDir } from "../../core/common.js";
21
24
  import { getConfigValueSource, loadConfig } from "../../core/config/config.js";
@@ -24,6 +27,7 @@ import { getConfigPath, getDataDir, getDbPath } from "../../core/paths.js";
24
27
  import { getStateDbPath, listPendingStateMigrations } from "../../core/state-db.js";
25
28
  import { DURATION_UNITS, parseDuration } from "../../core/time.js";
26
29
  import { resolveWriteTarget } from "../../core/write-source.js";
30
+ import { findSourceForPath, resolveSourceEntries } from "../../indexer/search/search-source.js";
27
31
  import { openDatabase } from "../../storage/database.js";
28
32
  import { listRecentImproveRuns } from "../../storage/repositories/improve-runs-repository.js";
29
33
  import { closeDatabase, openReadonlyExistingDatabase } from "../../storage/repositories/index-connection.js";
@@ -40,11 +44,12 @@ import { akmMetrics } from "../metrics/metrics-cli.js";
40
44
  import { isRetireProposal, proposalWaitingSince } from "../proposal/proposal-types.js";
41
45
  import { diffLoadedProposal, resolveProposalIdIn } from "../proposal/repository.js";
42
46
  import { validateProposal } from "../proposal/validators/proposals.js";
43
- import { akmSearch } from "../read/search.js";
47
+ import { akmSearch, normalizeScopeFilters, parseBeliefFilterMode } from "../read/search.js";
44
48
  import { akmShowUnified } from "../read/show.js";
45
49
  import { assembleInfo } from "../sources/info.js";
46
50
  import { akmListSources } from "../sources/installed-stashes.js";
47
- import { readBundleGitStatus, readRecentCommits } from "./git-read.js";
51
+ import { readDocumentSource } from "./document-source.js";
52
+ import { readBundleGitStatus, readFileRevision, readRecentCommits } from "./git-read.js";
48
53
  import { isSecretEnvName, redactForUi } from "./redact.js";
49
54
  const DEFAULT_LIST_LIMIT = 50;
50
55
  const MAX_LIST_LIMIT = 200;
@@ -236,6 +241,13 @@ async function bundles() {
236
241
  source: source.source,
237
242
  path: source.path ?? null,
238
243
  writable: source.writable,
244
+ enabled: config.bundles?.[source.name]?.enabled !== false,
245
+ writableSetting: config.bundles?.[source.name]?.writable,
246
+ components: source.components.map((component) => ({
247
+ ...component,
248
+ configured: !!config.bundles?.[source.name]?.components?.[component.name],
249
+ })),
250
+ settingsSource: configSource(["bundles", source.name]),
239
251
  version: source.version ?? null,
240
252
  exists: source.status.exists,
241
253
  itemCount: source.itemCount,
@@ -475,6 +487,8 @@ async function search(query) {
475
487
  limit: limitParam(query, 20),
476
488
  // `--from <name>` narrows to one configured bundle; absent means every local bundle, as `akm search` does.
477
489
  source: bundle ?? "local",
490
+ belief: parseBeliefFilterMode(query.get("belief") || "current"),
491
+ filters: normalizeScopeFilters(Object.fromEntries(["user", "agent", "run", "channel"].map((key) => [key, query.get(`scope_${key}`) ?? ""]))),
478
492
  readOnly: true,
479
493
  });
480
494
  }
@@ -528,7 +542,81 @@ async function asset(query) {
528
542
  }
529
543
  const shown = await akmShowUnified({ ref, readOnly: true });
530
544
  const durableRef = shown.path ? durableRefForPath(shown.path) : null;
531
- return { redacted: false, durableRef, asset: shown, feedback: feedbackFor(durableRef) };
545
+ const content = shown.content ?? "";
546
+ const headings = parseMarkdownToc(content).headings;
547
+ const slugs = uniqueHeadingSlugs(headings);
548
+ const links = {};
549
+ if (shown.path && durableRef) {
550
+ const owner = parseBundleRef(durableRef).bundle;
551
+ const db = openReadonlyExistingDatabase(getDbPath());
552
+ if (db) {
553
+ try {
554
+ for (const raw of markdownLinkTargets(content, false)) {
555
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(raw))
556
+ continue;
557
+ const [file, fragment] = raw.split("#", 2);
558
+ if (!file?.toLowerCase().endsWith(".md") || file.startsWith("/"))
559
+ continue;
560
+ let decoded;
561
+ try {
562
+ decoded = decodeURIComponent(file);
563
+ }
564
+ catch {
565
+ continue;
566
+ }
567
+ const id = getEntryIdByFilePath(db, path.resolve(path.dirname(shown.path), decoded));
568
+ const target = id === undefined ? undefined : getItemRefById(db, id);
569
+ if (target && parseBundleRef(target).bundle === owner)
570
+ links[raw] = target + (fragment ? `#${fragment}` : "");
571
+ }
572
+ }
573
+ finally {
574
+ closeDatabase(db);
575
+ }
576
+ }
577
+ }
578
+ return {
579
+ redacted: false,
580
+ durableRef,
581
+ bundle: durableRef ? parseBundleRef(durableRef).bundle : null,
582
+ asset: shown,
583
+ feedback: feedbackFor(durableRef),
584
+ feedbackRequireReason: loadConfig().feedback?.requireReason !== false,
585
+ links,
586
+ headings: headings.map((heading, i) => ({ ...heading, slug: slugs[i] })),
587
+ };
588
+ }
589
+ async function assetHistory(query) {
590
+ const selected = await asset(query);
591
+ if (selected.redacted || !selected.asset?.path)
592
+ return { available: false, commits: [] };
593
+ const source = findSourceForPath(selected.asset.path, resolveSourceEntries(undefined, loadConfig()));
594
+ if (!source)
595
+ return { available: false, commits: [] };
596
+ const file = path.relative(source.path, selected.asset.path).split(path.sep).join("/");
597
+ if (file.startsWith("../") || path.isAbsolute(file))
598
+ throw new UsageError("Asset path is outside its bundle.");
599
+ const commits = await readRecentCommits(source.path, limitParam(query, 30), file);
600
+ const revision = query.get("revision");
601
+ if (revision && !commits?.some((commit) => commit.sha === revision))
602
+ throw new UsageError("Choose a revision from this asset's history.");
603
+ const diff = revision ? await readFileRevision(source.path, file, revision) : null;
604
+ const before = capSide(diff?.before ?? null);
605
+ const after = capSide(diff?.after ?? null);
606
+ return {
607
+ available: commits !== null,
608
+ commits: commits ?? [],
609
+ ...(diff
610
+ ? {
611
+ diff: {
612
+ before: before.text,
613
+ after: after.text,
614
+ unified: diff.unified.slice(0, DIFF_SIDE_MAX_CHARS),
615
+ truncated: before.truncated || after.truncated || diff.unified.length > DIFF_SIDE_MAX_CHARS,
616
+ },
617
+ }
618
+ : {}),
619
+ };
532
620
  }
533
621
  // ── Routing ──────────────────────────────────────────────────────────────────
534
622
  const ROUTES = {
@@ -540,6 +628,8 @@ const ROUTES = {
540
628
  "/api/changes": changes,
541
629
  "/api/search": search,
542
630
  "/api/asset": asset,
631
+ "/api/asset/history": assetHistory,
632
+ "/api/asset/source": (query) => readDocumentSource(query.get("ref") ?? ""),
543
633
  };
544
634
  const PROPOSAL_ROUTE = /^\/api\/proposals\/([^/]+)$/;
545
635
  /** What {@link handleApiRequest} returns for a path that is not an API route. */
@@ -0,0 +1,51 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { createHash } from "node:crypto";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
8
+ import { parseRefInput } from "../../core/asset/resolve-ref.js";
9
+ import { loadConfig } from "../../core/config/config.js";
10
+ import { UsageError } from "../../core/errors.js";
11
+ import { resolveBundleWriteTarget } from "../../core/mutation-target.js";
12
+ import { assertAkmAssetWrite } from "../../core/write-source.js";
13
+ import { closeDatabase, openReadonlyExistingDatabase } from "../../storage/repositories/index-connection.js";
14
+ import { getEntryIdByFilePath, getItemRefById } from "../../storage/repositories/index-entries-repository.js";
15
+ import { akmShowUnified } from "../read/show.js";
16
+ import { redactUrlsInText } from "./redact.js";
17
+ /** Authoring reads are limited to indexed, writable AKM knowledge/memory files, never arbitrary paths. */
18
+ export async function readDocumentSource(ref) {
19
+ const parsed = parseRefInput(ref);
20
+ if (parsed.type !== "knowledge" && parsed.type !== "memory")
21
+ throw new UsageError("Only knowledge and memory support source editing in this UI.");
22
+ const shown = await akmShowUnified({ ref, readOnly: true });
23
+ if (!shown.path)
24
+ throw new UsageError("The document has no local source file.");
25
+ const db = openReadonlyExistingDatabase();
26
+ let canonical;
27
+ if (db) {
28
+ try {
29
+ const id = getEntryIdByFilePath(db, shown.path);
30
+ canonical = id === undefined ? undefined : (getItemRefById(db, id) ?? undefined);
31
+ }
32
+ finally {
33
+ closeDatabase(db);
34
+ }
35
+ }
36
+ if (!canonical)
37
+ throw new UsageError("Only indexed documents can be edited.");
38
+ const identity = parseRefInput(canonical);
39
+ const target = resolveBundleWriteTarget(loadConfig(), identity.origin);
40
+ assertAkmAssetWrite(target.source);
41
+ const expectedPath = assetPathForName(parsed.type, path.join(target.source.path, stashDirFor(parsed.type)), identity.name);
42
+ if (path.resolve(expectedPath) !== path.resolve(shown.path))
43
+ throw new UsageError("The indexed source no longer matches the writable document target. Refresh the index before editing.");
44
+ const raw = fs.readFileSync(shown.path);
45
+ const content = raw.toString("utf8");
46
+ if (!Buffer.from(content, "utf8").equals(raw))
47
+ throw new UsageError("This source is not valid UTF-8; edit it outside the UI.");
48
+ if (redactUrlsInText(content) !== content)
49
+ throw new UsageError("This document contains URL credentials hidden by the UI. Edit it outside the UI to preserve those values.");
50
+ return { ref: canonical, type: parsed.type, content, revision: createHash("sha256").update(raw).digest("hex") };
51
+ }
@@ -73,10 +73,17 @@ export function classifyCommitOrigin(subject) {
73
73
  return "other";
74
74
  }
75
75
  /** The `limit` most recent commits of the repository at `repoDir`, newest first; `null` when it is not a git root. */
76
- export async function readRecentCommits(repoDir, limit) {
76
+ export async function readRecentCommits(repoDir, limit, file) {
77
77
  if (!isGitRoot(repoDir))
78
78
  return null;
79
- const out = await git(repoDir, ["log", "-n", String(limit), "--name-only", "--format=%x1e%H%x1f%cI%x1f%an%x1f%s"]);
79
+ const out = await git(repoDir, [
80
+ "log",
81
+ "-n",
82
+ String(limit),
83
+ "--name-only",
84
+ "--format=%x1e%H%x1f%cI%x1f%an%x1f%s",
85
+ ...(file ? ["--", file] : []),
86
+ ]);
80
87
  if (out === null)
81
88
  return [];
82
89
  const commits = [];
@@ -99,3 +106,12 @@ export async function readRecentCommits(repoDir, limit) {
99
106
  }
100
107
  return commits;
101
108
  }
109
+ /** Caller supplies an indexed asset path inside this bundle and a commit ID it took from {@link readRecentCommits}. */
110
+ export async function readFileRevision(repoDir, file, sha) {
111
+ const after = await git(repoDir, ["show", `${sha}:${file}`]);
112
+ if (after === null)
113
+ return null;
114
+ const before = await git(repoDir, ["show", `${sha}^:${file}`]);
115
+ const unified = await git(repoDir, ["show", "--no-ext-diff", "--no-textconv", "--format=", sha, "--", file]);
116
+ return { before, after, unified: unified ?? "" };
117
+ }
@@ -2,14 +2,14 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
- * The only writes `akm ui` makes: accept and reject a proposal, and accept
5
+ * Proposal writes `akm ui` makes: accept and reject a proposal, and accept
6
6
  * several at once. Each handler calls the function `akm proposal accept` /
7
7
  * `reject` calls, with the arguments the CLI passes, so a UI decision is
8
8
  * recorded exactly like a CLI one (same state.db rows, events and commit).
9
9
  *
10
10
  * The server decides whether a request may reach this module (token header,
11
11
  * Origin, content type, body size); see `server.ts`. Everything else in the UI
12
- * stays read-only and lives in `api.ts`.
12
+ * is either a read in `api.ts` or an explicit action in `workspace-actions.ts`.
13
13
  */
14
14
  import { AkmError, TransientError, UsageError } from "../../core/errors.js";
15
15
  import { acquireAssetMutationLease } from "../../indexer/index-writer-lock.js";
@@ -56,6 +56,8 @@ export function classifyProposalError(error) {
56
56
  const message = error instanceof Error ? error.message : String(error);
57
57
  const byMessage = codeForMessage(message);
58
58
  if (error instanceof AkmError) {
59
+ if (error.code === "ASSET_CONTENT_CHANGED")
60
+ return { status: 409, code: error.code, message };
59
61
  if (byMessage === "PROPOSAL_STALE" || byMessage === "PROPOSAL_NOT_PENDING") {
60
62
  return { status: 409, code: byMessage, message };
61
63
  }
@@ -112,7 +114,7 @@ function batchIds(body) {
112
114
  * reject functions would wait up to ten minutes for it, which in a browser is a
113
115
  * hung button; the probe turns a held lease into an immediate, retryable 409.
114
116
  */
115
- async function assertWriteLeaseFree(purpose) {
117
+ export async function assertWriteLeaseFree(purpose) {
116
118
  const lease = await acquireAssetMutationLease({ mode: "try", purpose });
117
119
  if (!lease) {
118
120
  throw new TransientError("Another akm process or request is writing to the bundle right now. Try again in a moment.", "ASSET_MUTATION_LEASE_HELD");
@@ -10,7 +10,7 @@
10
10
  * `Host` header must be the address the server is bound to, which stops a web
11
11
  * page from reaching it through DNS rebinding. Static UI files are served
12
12
  * without the token and embed no data. Only GET (and HEAD) is accepted, except
13
- * for the three proposal writes (accept, reject, accept several): a POST to one
13
+ * for explicit proposal and workspace actions: a POST to one
14
14
  * of those must carry the token in the `X-Akm-Ui-Token` header (never the
15
15
  * query string), a JSON content type and an `Origin` that is this server's own,
16
16
  * so another web page cannot make the browser send one.
@@ -27,6 +27,7 @@ import chartsJs from "../../assets/ui/charts.js" with { type: "text" };
27
27
  import configJs from "../../assets/ui/config.js" with { type: "text" };
28
28
  import diffJs from "../../assets/ui/diff.js" with { type: "text" };
29
29
  import diffViewJs from "../../assets/ui/diff-view.js" with { type: "text" };
30
+ import editorJs from "../../assets/ui/editor.js" with { type: "text" };
30
31
  import indexHtml from "../../assets/ui/index.html" with { type: "text" };
31
32
  import libJs from "../../assets/ui/lib.js" with { type: "text" };
32
33
  import markdownJs from "../../assets/ui/markdown.js" with { type: "text" };
@@ -40,9 +41,12 @@ import { getDirname } from "../../runtime.js";
40
41
  import { handleApiRequest, NO_SUCH_ROUTE } from "./api.js";
41
42
  import { classifyProposalError, handleWriteRequest, matchWriteRoute } from "./proposal-actions.js";
42
43
  import { scrubUrlCredentials } from "./redact.js";
44
+ import { classifyWorkspaceError, handleWorkspaceAction, matchWorkspaceAction, } from "./workspace-actions.js";
43
45
  export const TOKEN_HEADER = "x-akm-ui-token";
44
46
  /** Largest request body a write route reads. */
45
47
  export const MAX_BODY_BYTES = 64 * 1024;
48
+ /** Largest body of a route that carries a whole Markdown document (`/api/asset/save`, `/api/knowledge/create`). */
49
+ export const MAX_DOCUMENT_BODY_BYTES = 8 * 1024 * 1024;
46
50
  const UI_DIR = path.join(getDirname(import.meta.url), "../../assets/ui");
47
51
  /**
48
52
  * The UI files embedded at build time. Disk stays primary (an edit shows
@@ -62,6 +66,7 @@ export const EMBEDDED_UI = {
62
66
  "config.js": configJs,
63
67
  "diff-view.js": diffViewJs,
64
68
  "diff.js": diffJs,
69
+ "editor.js": editorJs,
65
70
  "lib.js": libJs,
66
71
  "markdown.js": markdownJs,
67
72
  "overview.js": overviewJs,
@@ -148,11 +153,11 @@ function originAllowed(origin, hostHeader) {
148
153
  function isJsonContentType(header) {
149
154
  return (header ?? "").split(";")[0]?.trim().toLowerCase() === "application/json";
150
155
  }
151
- /** The request body as text, or `null` when it is larger than {@link MAX_BODY_BYTES}. */
152
- function readBody(req) {
156
+ /** The request body as text, or `null` when it is larger than `limit` bytes. */
157
+ function readBody(req, limit) {
153
158
  return new Promise((resolve, reject) => {
154
159
  const declared = Number(req.headers["content-length"]);
155
- if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
160
+ if (Number.isFinite(declared) && declared > limit) {
156
161
  resolve(null);
157
162
  return;
158
163
  }
@@ -161,7 +166,7 @@ function readBody(req) {
161
166
  let tooLarge = false;
162
167
  req.on("data", (chunk) => {
163
168
  size += chunk.length;
164
- if (size > MAX_BODY_BYTES)
169
+ if (size > limit)
165
170
  tooLarge = true;
166
171
  else
167
172
  chunks.push(chunk);
@@ -237,7 +242,8 @@ export async function startUiServer(options = {}) {
237
242
  }
238
243
  const url = new URL(req.url ?? "/", "http://akm-ui.invalid");
239
244
  const isApi = url.pathname === "/api" || url.pathname.startsWith("/api/");
240
- const writeRoute = req.method === "POST" ? matchWriteRoute(url.pathname) : null;
245
+ const workspace = req.method === "POST" ? matchWorkspaceAction(url.pathname) : null;
246
+ const writeRoute = req.method === "POST" ? (workspace ? { workspace } : matchWriteRoute(url.pathname)) : null;
241
247
  if (writeRoute) {
242
248
  await handleWrite(req, res, writeRoute);
243
249
  return;
@@ -255,7 +261,7 @@ export async function startUiServer(options = {}) {
255
261
  }
256
262
  }
257
263
  if (!readMethod) {
258
- sendError(req, res, 405, "METHOD_NOT_ALLOWED", "Only GET is accepted here. akm ui writes only through POST /api/proposals/:id/accept, /reject and /api/proposals/accept.", {
264
+ sendError(req, res, 405, "METHOD_NOT_ALLOWED", "Only GET is accepted here. Use an explicit UI action to make changes.", {
259
265
  Allow: "GET, HEAD",
260
266
  });
261
267
  return;
@@ -271,7 +277,9 @@ export async function startUiServer(options = {}) {
271
277
  }
272
278
  catch (error) {
273
279
  const code = error instanceof AkmError ? error.code : "INTERNAL_ERROR";
274
- sendError(req, res, statusFor(error), code, error instanceof Error ? error.message : String(error));
280
+ // The editor's source read refuses what a save would refuse, with the same status.
281
+ const status = url.pathname === "/api/asset/source" ? classifyWorkspaceError(error).status : statusFor(error);
282
+ sendError(req, res, status, code, error instanceof Error ? error.message : String(error));
275
283
  }
276
284
  return;
277
285
  }
@@ -288,7 +296,7 @@ export async function startUiServer(options = {}) {
288
296
  });
289
297
  res.end(req.method === "HEAD" ? undefined : asset.body);
290
298
  }
291
- /** The three proposal writes. Every check runs before the body is read. */
299
+ /** Explicit write actions. Every check runs before the body is read. */
292
300
  async function handleWrite(req, res, route) {
293
301
  // The header only: a token in the URL ends up in history and logs, and a link can carry it.
294
302
  const given = req.headers[TOKEN_HEADER];
@@ -306,9 +314,11 @@ export async function startUiServer(options = {}) {
306
314
  sendError(req, res, 415, "UNSUPPORTED_MEDIA_TYPE", "Writes must be sent as Content-Type: application/json.");
307
315
  return;
308
316
  }
309
- const text = await readBody(req);
317
+ const document = "workspace" in route && (route.workspace === "document-save" || route.workspace === "knowledge-create");
318
+ const limit = document ? MAX_DOCUMENT_BODY_BYTES : MAX_BODY_BYTES;
319
+ const text = await readBody(req, limit);
310
320
  if (text === null) {
311
- sendError(req, res, 413, "BODY_TOO_LARGE", `The request body is larger than ${MAX_BODY_BYTES} bytes.`, {
321
+ sendError(req, res, 413, "BODY_TOO_LARGE", `The request body is larger than ${limit} bytes.`, {
312
322
  Connection: "close",
313
323
  });
314
324
  return;
@@ -322,11 +332,13 @@ export async function startUiServer(options = {}) {
322
332
  return;
323
333
  }
324
334
  try {
325
- const data = await handleWriteRequest(route, body);
335
+ const data = "workspace" in route
336
+ ? await handleWorkspaceAction(route.workspace, body)
337
+ : await handleWriteRequest(route, body);
326
338
  sendJson(req, res, 200, { ok: true, data: scrubUrlCredentials(data) });
327
339
  }
328
340
  catch (error) {
329
- const { status, code, message } = classifyProposalError(error);
341
+ const { status, code, message } = "workspace" in route ? classifyWorkspaceError(error) : classifyProposalError(error);
330
342
  sendError(req, res, status, code, message);
331
343
  }
332
344
  }
@@ -2,7 +2,7 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
- * `akm ui` — a local web UI, read-only apart from accepting and rejecting proposals, over the same data the CLI reports:
5
+ * `akm ui` — a local web UI over the same data and explicit operations the CLI offers:
6
6
  * health and metrics, bundles, config, the proposal queue, recent changes and
7
7
  * search. See `docs/reference/ui-api.md` for the API the page reads.
8
8
  */
@@ -23,7 +23,7 @@ function parsePort(raw) {
23
23
  export const uiCommand = defineJsonCommand({
24
24
  meta: {
25
25
  name: "ui",
26
- description: "Experimental: serve a local web UI (health, bundles, config, proposals, recent changes, search) and open it in your browser. It is read-only except that you can accept and reject proposals in it. The URL carries a per-launch token. Press Ctrl-C to stop.",
26
+ description: "Experimental: serve a local web UI for browsing, feedback, bundle management, Markdown authoring and proposal decisions. Browsing is read-only; explicit actions use existing AKM operations. The URL carries a per-launch token. Press Ctrl-C to stop.",
27
27
  },
28
28
  args: {
29
29
  port: { type: "string", description: "Port to listen on (default: a random free port)" },
@@ -40,7 +40,7 @@ export const uiCommand = defineJsonCommand({
40
40
  const host = args.host?.trim() || "127.0.0.1";
41
41
  const server = await startUiServer({ host, port: parsePort(args.port) });
42
42
  if (!isLoopbackBind(host)) {
43
- warn(`[ui] Listening on ${host}, not just this machine: anyone who can reach it and has the token can read your stash, config and proposals. Keep the URL private.`);
43
+ warn(`[ui] Listening on ${host}, not just this machine: anyone who can reach it and has the token can read your data and use the UI's write actions. Keep the URL private.`);
44
44
  }
45
45
  // The URL is the command's output, so it goes to stdout like any other result.
46
46
  process.stdout.write(`akm ui: ${server.url}\n`);