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.
- package/CHANGELOG.md +97 -0
- package/STABILITY.md +25 -8
- package/dist/api.d.ts +6 -0
- package/dist/api.js +12 -1
- package/dist/assets/prompts/consolidate-pair-check.md +2 -0
- package/dist/assets/prompts/consolidate-pair-same-age.md +1 -0
- package/dist/assets/prompts/consolidate-pair-terms.md +6 -0
- package/dist/assets/prompts/consolidate-pair.md +4 -12
- 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/health.js +1 -2
- package/dist/commands/improve/consolidate/pair-pass.js +59 -31
- package/dist/commands/improve/extract.js +18 -12
- package/dist/commands/improve/loop-stages.js +2 -2
- package/dist/commands/improve/memory/memory-belief.js +2 -2
- package/dist/commands/proposal/repository.js +44 -13
- package/dist/commands/read/curate.js +78 -19
- package/dist/commands/read/knowledge.js +109 -77
- package/dist/commands/read/search-cli.js +25 -11
- package/dist/commands/read/search.js +3 -3
- package/dist/commands/read/show.js +47 -1
- 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/adapter/adapters/akm-adapter.js +1 -0
- package/dist/core/adapter/adapters/llm-wiki-adapter.js +3 -5
- package/dist/core/adapter/adapters/okf-adapter.js +3 -16
- package/dist/core/asset/akm-markdown.js +19 -3
- package/dist/core/asset/markdown-link-targets.js +43 -0
- 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/indexer/passes/metadata.js +17 -0
- package/dist/indexer/scan/doc-to-entry.js +1 -0
- package/dist/indexer/search/db-search.js +16 -7
- package/dist/indexer/search/search-hit-enrichers.js +3 -3
- package/dist/llm/memory-infer.js +23 -14
- package/dist/output/shapes/curate.js +3 -1
- package/dist/output/shapes/helpers.js +6 -0
- package/dist/output/text/show-format.js +7 -0
- package/dist/scripts/akm-migrate-node.js +1820 -329
- package/dist/scripts/akm-migrate.js +1820 -329
- package/dist/storage/repositories/index-entries-repository.js +16 -0
- package/dist/storage/repositories/index-fts-repository.js +28 -6
- package/dist/storage/repositories/index-vec-repository.js +19 -3
- package/dist/workflows/exec/environment.js +4 -8
- package/dist/workflows/parser.js +13 -4
- package/docs/architecture/0.10-process-rules.md +9 -7
- package/docs/architecture/architecture.md +4 -0
- package/docs/architecture/internals/storage-locations.md +1 -1
- package/docs/architecture/persisted-data-compat.md +1 -1
- package/docs/guides/author-workflows.md +78 -28
- package/docs/guides/bundles.md +16 -2
- package/docs/reference/api.md +19 -1
- package/docs/reference/cli.md +90 -18
- package/docs/reference/env-and-secrets.md +11 -0
- package/docs/reference/memory.md +7 -6
- package/docs/reference/ui-api.md +84 -8
- package/docs/reference/workflow-schema.md +63 -32
- package/package.json +2 -1
package/dist/commands/ui/api.js
CHANGED
|
@@ -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`.
|
|
6
|
-
* `proposal-actions.ts`; nothing in this file writes.
|
|
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 {
|
|
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
|
-
|
|
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, [
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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 >
|
|
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 >
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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 ${
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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`);
|