akm-cli 0.10.261009-beta → 0.10.261010-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/STABILITY.md +15 -4
- package/dist/assets/prompts/memory-infer-system.md +1 -1
- package/dist/assets/prompts/memory-infer-user.md +3 -2
- package/dist/assets/ui/app.js +59 -2
- package/dist/assets/ui/asset.js +71 -6
- package/dist/assets/ui/bundles.js +80 -3
- package/dist/assets/ui/diff-view.js +1 -1
- package/dist/assets/ui/editor.js +103 -0
- package/dist/assets/ui/index.html +2 -0
- package/dist/assets/ui/lib.js +51 -1
- package/dist/assets/ui/markdown.js +86 -19
- package/dist/assets/ui/search.js +25 -5
- package/dist/assets/ui/styles.css +70 -0
- package/dist/commands/health/improve-metrics.js +11 -1
- package/dist/commands/health/md-report.js +3 -3
- package/dist/commands/improve/loop-stages.js +2 -2
- package/dist/commands/read/curate.js +1 -1
- package/dist/commands/read/knowledge.js +109 -77
- package/dist/commands/sources/bundle-cli.js +4 -6
- package/dist/commands/sources/installed-stashes.js +10 -19
- package/dist/commands/ui/api.js +95 -5
- package/dist/commands/ui/document-source.js +51 -0
- package/dist/commands/ui/git-read.js +18 -2
- package/dist/commands/ui/proposal-actions.js +5 -3
- package/dist/commands/ui/server.js +25 -13
- package/dist/commands/ui/ui-cli.js +3 -3
- package/dist/commands/ui/workspace-actions.js +264 -0
- package/dist/commands/workflow-cli.js +3 -1
- package/dist/core/asset/akm-markdown.js +19 -3
- package/dist/core/asset/markdown.js +14 -6
- package/dist/core/config/config.js +6 -4
- package/dist/core/non-task-input.js +67 -3
- package/dist/core/write-source.js +2 -2
- package/dist/indexer/index-writer-lock.js +5 -1
- package/dist/indexer/passes/memory-inference.js +28 -6
- package/dist/llm/memory-infer.js +23 -14
- package/dist/scripts/akm-migrate-node.js +18 -10
- package/dist/scripts/akm-migrate.js +18 -10
- package/dist/workflows/exec/environment.js +4 -8
- package/dist/workflows/parser.js +13 -4
- package/docs/architecture/0.10-process-rules.md +5 -3
- package/docs/architecture/internals/storage-locations.md +1 -1
- package/docs/guides/author-workflows.md +78 -28
- package/docs/guides/bundles.md +16 -2
- package/docs/reference/cli.md +39 -16
- package/docs/reference/env-and-secrets.md +11 -0
- package/docs/reference/ui-api.md +84 -8
- package/docs/reference/workflow-schema.md +63 -32
- package/package.json +1 -1
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
* (knowledge and memory) from the CLI. Extracted to keep the CLI entry point
|
|
9
9
|
* focused on command definitions and routing.
|
|
10
10
|
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
11
12
|
import fs from "node:fs";
|
|
12
13
|
import path from "node:path";
|
|
13
14
|
import { parse as yamlParse } from "yaml";
|
|
14
15
|
import { detectAdapterId } from "../../core/adapter/detect-adapter.js";
|
|
16
|
+
import { normalizeAkmMarkdown } from "../../core/asset/akm-markdown.js";
|
|
15
17
|
import { assertFlatAssetName, combineCreatePath, normalizeCreateSubPath } from "../../core/asset/asset-create.js";
|
|
16
18
|
import { assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
|
|
17
19
|
import { assembleAsset } from "../../core/asset/asset-serialize.js";
|
|
@@ -24,6 +26,7 @@ import { resolveBundleWriteTarget, resolveMutationTarget } from "../../core/muta
|
|
|
24
26
|
import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
|
|
25
27
|
import { warn } from "../../core/warn.js";
|
|
26
28
|
import { commitWriteTargetBoundary, formatRefForMessage, recordWriteTargetPath, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
|
|
29
|
+
import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
|
|
27
30
|
import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
|
|
28
31
|
import { deriveInstallations, slugForPath } from "../../indexer/installations.js";
|
|
29
32
|
import { resolveSourceEntries } from "../../indexer/search/search-source.js";
|
|
@@ -502,16 +505,10 @@ export function resolveSupersedesForWrite(rawRefs, target, targetFlag) {
|
|
|
502
505
|
}
|
|
503
506
|
return plan;
|
|
504
507
|
}
|
|
505
|
-
// ── Asset writing ────────────────────────────────────────────────────────────
|
|
506
|
-
/**
|
|
507
|
-
* Write a markdown asset (knowledge or memory) to the resolved write target.
|
|
508
|
-
*
|
|
509
|
-
* Resolves the write target via the v1 precedence chain (`--bundle` →
|
|
510
|
-
* `defaultWriteTarget` → working stash), validates the path is within the
|
|
511
|
-
* type root, enforces `--force` semantics, and delegates the actual write
|
|
512
|
-
* to `writeAssetToSource`.
|
|
513
|
-
*/
|
|
514
508
|
export async function writeMarkdownAsset(options) {
|
|
509
|
+
if (options.expectedContentHash !== undefined && !/^[0-9a-f]{64}$/.test(options.expectedContentHash)) {
|
|
510
|
+
throw new UsageError("The expected content hash must be a lowercase hex SHA-256 digest (64 characters).", "INVALID_FLAG_VALUE");
|
|
511
|
+
}
|
|
515
512
|
const cfg = loadConfig();
|
|
516
513
|
// `--name` is the flat asset name; `--path` is the subdirectory under the
|
|
517
514
|
// type root. Combine them into the nested name the path resolver expects.
|
|
@@ -524,16 +521,10 @@ export async function writeMarkdownAsset(options) {
|
|
|
524
521
|
const { target } = resolved;
|
|
525
522
|
const { source, config } = target;
|
|
526
523
|
const typeRoot = path.join(source.path, options.type === "knowledge" ? "knowledge" : "memories");
|
|
527
|
-
// Pre-flight: existence + force semantics. The helper itself overwrites
|
|
528
|
-
// unconditionally; the CLI surfaces a friendlier UsageError before any
|
|
529
|
-
// disk activity when --force is absent.
|
|
530
524
|
const assetPath = assetPathForName(options.type, typeRoot, normalizedName);
|
|
531
525
|
if (!isWithin(assetPath, typeRoot)) {
|
|
532
526
|
throw new UsageError(`Resolved ${options.type} path escapes the stash: "${normalizedName}"`);
|
|
533
527
|
}
|
|
534
|
-
if (fs.existsSync(assetPath) && !options.force) {
|
|
535
|
-
throw new UsageError(`${options.type === "knowledge" ? "Knowledge" : "Memory"} "${normalizedName}" already exists. Re-run with --force to overwrite it.`, "RESOURCE_ALREADY_EXISTS");
|
|
536
|
-
}
|
|
537
528
|
// A correction cannot supersede ITSELF. Under `--force` the ref resolves to
|
|
538
529
|
// the very file this command is about to overwrite, and the demotion would
|
|
539
530
|
// immediately mark the fresh correction superseded (plus a self-xref) —
|
|
@@ -544,72 +535,113 @@ export async function writeMarkdownAsset(options) {
|
|
|
544
535
|
throw new UsageError(`--supersedes ${item.ref} resolves to the asset being written ("${resolved.displayRef}") — a correction cannot supersede itself.`, "INVALID_FLAG_VALUE", "Write the correction under a different --name, or drop --supersedes when overwriting an asset in place with --force.");
|
|
545
536
|
}
|
|
546
537
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
const reason = item.reason ?? "target is not writable";
|
|
561
|
-
warn(`Warning: superseded asset ${item.ref} was NOT demoted (${reason}). ` +
|
|
562
|
-
"The correction was written and cites it in xrefs; demote the old asset where it is writable.");
|
|
563
|
-
superseded.push({ ref: item.ref, applied: false, reason });
|
|
564
|
-
continue;
|
|
565
|
-
}
|
|
566
|
-
// The demotion round-trips the old file's frontmatter through the YAML
|
|
567
|
-
// parser. A malformed block would silently fall back to the lossy lenient
|
|
568
|
-
// scanner and re-serializing that result destroys list/nested values —
|
|
569
|
-
// skip instead of rewriting, mirroring mergeXrefsIntoContent's
|
|
570
|
-
// abort-on-malformed policy (the correction itself still writes).
|
|
571
|
-
let oldFrontmatter = null;
|
|
572
|
-
try {
|
|
573
|
-
oldFrontmatter = parseFrontmatter(fs.readFileSync(item.filePath, "utf8")).frontmatter;
|
|
574
|
-
}
|
|
575
|
-
catch {
|
|
576
|
-
// Unreadable file — let writeSupersededEdge surface the real fs error.
|
|
577
|
-
}
|
|
578
|
-
if (oldFrontmatter?.trim() && !isParseableYamlMapping(oldFrontmatter)) {
|
|
579
|
-
const reason = "its existing frontmatter is not a parseable YAML mapping — rewriting it would drop the values the parser could not read";
|
|
580
|
-
warn(`Warning: superseded asset ${item.ref} was NOT demoted (${reason}). ` +
|
|
581
|
-
"The correction was written and cites it in xrefs; fix the old asset's frontmatter and re-run the correction with --force.");
|
|
582
|
-
superseded.push({ ref: item.ref, applied: false, reason });
|
|
583
|
-
continue;
|
|
538
|
+
// The lease covers only what reads or changes authored files: the existence
|
|
539
|
+
// and revision checks, the write, the demotions and the boundary commit. The
|
|
540
|
+
// commit pushes inside the lease on purpose: a push from a writer that has
|
|
541
|
+
// released it would race the next writer's commit on the same branch (the
|
|
542
|
+
// conflicting-commit hazard the lease exists for), and the sync write path
|
|
543
|
+
// (`withWriteTargetMutation`) draws the same boundary. Indexing is a
|
|
544
|
+
// regenerable cache and runs after release.
|
|
545
|
+
const written = await withAssetMutationLease("markdown-write", async () => {
|
|
546
|
+
// Pre-flight: existence + force semantics. The helper itself overwrites
|
|
547
|
+
// unconditionally; the CLI surfaces a friendlier UsageError before any
|
|
548
|
+
// disk activity when --force is absent.
|
|
549
|
+
if (fs.existsSync(assetPath) && !options.force) {
|
|
550
|
+
throw new UsageError(`${options.type === "knowledge" ? "Knowledge" : "Memory"} "${normalizedName}" already exists. Re-run with --force to overwrite it.`, "RESOURCE_ALREADY_EXISTS");
|
|
584
551
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
if (
|
|
595
|
-
|
|
552
|
+
if (options.expectedContentHash !== undefined) {
|
|
553
|
+
let existing;
|
|
554
|
+
try {
|
|
555
|
+
existing = fs.readFileSync(assetPath);
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
if (error.code !== "ENOENT")
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
if (!existing || createHash("sha256").update(existing).digest("hex") !== options.expectedContentHash) {
|
|
562
|
+
throw new UsageError("The document changed or was removed after it was loaded. Reload its source before saving.", "ASSET_CONTENT_CHANGED");
|
|
563
|
+
}
|
|
564
|
+
// Unchanged when the live bytes are the submission itself (an untouched
|
|
565
|
+
// document keeps its bytes, missing metadata included) or exactly what
|
|
566
|
+
// the writer would write for it (e.g. the submission only lacks the
|
|
567
|
+
// final line break the writer adds).
|
|
568
|
+
const unchanged = existing.equals(Buffer.from(options.content, "utf8")) ||
|
|
569
|
+
existing.equals(Buffer.from(normalizeAkmMarkdown(options.content, resolved.ref.type), "utf8"));
|
|
570
|
+
if (unchanged && !options.supersedes?.length) {
|
|
571
|
+
return { ref: resolved.displayRef, path: assetPath, stashDir: source.path, unchanged: true };
|
|
596
572
|
}
|
|
597
573
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
574
|
+
const result = await writeAssetToSource(source, config, resolved.ref, options.content);
|
|
575
|
+
const durableRef = result.ref;
|
|
576
|
+
result.ref = resolved.displayRef;
|
|
577
|
+
// SPEC-5 (--supersedes): demote each superseded asset by mutating its
|
|
578
|
+
// frontmatter (`beliefState: superseded` + sorted-set-append `supersededBy`;
|
|
579
|
+
// every other key and the body are preserved). Ordered BEFORE
|
|
580
|
+
// commitWriteTargetBoundary so a git target batches the correction and the
|
|
581
|
+
// demoted incumbent into the single boundary commit instead of leaving the
|
|
582
|
+
// metadata edit as dirty residue after it.
|
|
583
|
+
const superseded = [];
|
|
584
|
+
const demotedByRoot = new Map();
|
|
585
|
+
for (const item of options.supersedes ?? []) {
|
|
586
|
+
if (!item.writable) {
|
|
587
|
+
const reason = item.reason ?? "target is not writable";
|
|
588
|
+
warn(`Warning: superseded asset ${item.ref} was NOT demoted (${reason}). ` +
|
|
589
|
+
"The correction was written and cites it in xrefs; demote the old asset where it is writable.");
|
|
590
|
+
superseded.push({ ref: item.ref, applied: false, reason });
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
// The demotion round-trips the old file's frontmatter through the YAML
|
|
594
|
+
// parser. A malformed block would silently fall back to the lossy lenient
|
|
595
|
+
// scanner and re-serializing that result destroys list/nested values —
|
|
596
|
+
// skip instead of rewriting, mirroring mergeXrefsIntoContent's
|
|
597
|
+
// abort-on-malformed policy (the correction itself still writes).
|
|
598
|
+
let oldFrontmatter = null;
|
|
599
|
+
try {
|
|
600
|
+
oldFrontmatter = parseFrontmatter(fs.readFileSync(item.filePath, "utf8")).frontmatter;
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
// Unreadable file — let writeSupersededEdge surface the real fs error.
|
|
604
|
+
}
|
|
605
|
+
if (oldFrontmatter?.trim() && !isParseableYamlMapping(oldFrontmatter)) {
|
|
606
|
+
const reason = "its existing frontmatter is not a parseable YAML mapping — rewriting it would drop the values the parser could not read";
|
|
607
|
+
warn(`Warning: superseded asset ${item.ref} was NOT demoted (${reason}). ` +
|
|
608
|
+
"The correction was written and cites it in xrefs; fix the old asset's frontmatter and re-run the correction with --force.");
|
|
609
|
+
superseded.push({ ref: item.ref, applied: false, reason });
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
// A demotion failure (fs error, concurrent delete, malformed YAML the
|
|
613
|
+
// pre-check missed) must NOT abort the correction: the new asset is
|
|
614
|
+
// already on disk, and bailing out here would skip the boundary commit and
|
|
615
|
+
// the write-path indexing below — leaving the correction unindexed and,
|
|
616
|
+
// on a git target, uncommitted (and a re-run hits RESOURCE_ALREADY_EXISTS).
|
|
617
|
+
// Degrade to the same applied:false report the non-writable path uses.
|
|
618
|
+
try {
|
|
619
|
+
const sameSource = path.resolve(item.stashRoot) === path.resolve(source.path);
|
|
620
|
+
writeSupersededEdge(item.filePath, durableRef);
|
|
621
|
+
if (sameSource) {
|
|
622
|
+
recordWriteTargetPath(source, item.filePath);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
const reason = `demotion failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
627
|
+
warn(`Warning: superseded asset ${item.ref} was NOT demoted (${reason}). ` +
|
|
628
|
+
"The correction was written and cites it in xrefs; demote the old asset manually or re-run the correction with --force.");
|
|
629
|
+
superseded.push({ ref: item.ref, applied: false, reason });
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
superseded.push({ ref: item.ref, applied: true });
|
|
633
|
+
const files = demotedByRoot.get(item.stashRoot) ?? [];
|
|
634
|
+
files.push(item.filePath);
|
|
635
|
+
demotedByRoot.set(item.stashRoot, files);
|
|
604
636
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
637
|
+
// 0.9.0 (issue #507): single batch commit at the write boundary for git
|
|
638
|
+
// targets. No-op for filesystem/primary-stash targets.
|
|
639
|
+
commitWriteTargetBoundary(target, `Update ${formatRefForMessage(resolved.ref)}`);
|
|
640
|
+
return { result, superseded, demotedByRoot };
|
|
641
|
+
});
|
|
642
|
+
if (written.unchanged)
|
|
643
|
+
return written;
|
|
644
|
+
const { result, superseded, demotedByRoot } = written;
|
|
613
645
|
// Write-path indexing: the asset is searchable immediately. Fail-open; reads
|
|
614
646
|
// no longer trigger reindexes, so keeping the index current is the writer's
|
|
615
647
|
// job. Demoted files reindex under their own containing root (usually the
|
|
@@ -139,14 +139,13 @@ const updateCommand = defineJsonCommand({
|
|
|
139
139
|
description: "Allow an update containing dangerous env keys (e.g. LD_PRELOAD, PATH). Use only after explicitly reviewing the staged bundle.",
|
|
140
140
|
default: false,
|
|
141
141
|
},
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
// never consults this flag. Mirrors `remove`'s `-y/--yes`.
|
|
142
|
+
// Deprecated no-op, accepted through 0.10 so scripts that pass it do not
|
|
143
|
+
// fail with UNKNOWN_FLAG. Deleting the previous directory of a read-only
|
|
144
|
+
// install whose content moved no longer asks: it is a re-fetchable cache.
|
|
146
145
|
yes: {
|
|
147
146
|
type: "boolean",
|
|
148
147
|
alias: "y",
|
|
149
|
-
description: "
|
|
148
|
+
description: "Deprecated, no effect: update no longer asks before deleting a moved read-only install directory. Accepted through 0.10 for existing scripts.",
|
|
150
149
|
default: false,
|
|
151
150
|
},
|
|
152
151
|
},
|
|
@@ -157,7 +156,6 @@ const updateCommand = defineJsonCommand({
|
|
|
157
156
|
target: args.target,
|
|
158
157
|
all: args.all,
|
|
159
158
|
force: args.force,
|
|
160
|
-
yes: args.yes,
|
|
161
159
|
allowDangerousEnvKeys: args["allow-dangerous-env-keys"],
|
|
162
160
|
});
|
|
163
161
|
}
|
|
@@ -611,15 +611,13 @@ function managedInstallViewOfPlainNpm(config, npmSource) {
|
|
|
611
611
|
* Sync a single registry-managed install and return the processed record:
|
|
612
612
|
* fetch into staging, audit, publish, write its lock entry, reindex.
|
|
613
613
|
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
* scripts; only the branch that can `rm -rf` needs a gate, not the whole
|
|
620
|
-
* command).
|
|
614
|
+
* Read-only installs are keyed by resolved revision, so every upstream change
|
|
615
|
+
* resolves a new content directory, and the previous one is deleted once the
|
|
616
|
+
* new one is published. That directory is a re-fetchable cache: local and
|
|
617
|
+
* writable installs never take this path, and a directory another bundle or
|
|
618
|
+
* lock entry still references is kept. So deleting it never asks for confirmation.
|
|
621
619
|
*/
|
|
622
|
-
async function updateManagedInstall(managed, force,
|
|
620
|
+
async function updateManagedInstall(managed, force, stashDir, allowDangerousEnvKeys) {
|
|
623
621
|
const { synced, staged } = await stageManagedUpdate(managed, force);
|
|
624
622
|
try {
|
|
625
623
|
await auditStagedUpdate(staged.auditRoot, managed.ref, allowDangerousEnvKeys);
|
|
@@ -642,12 +640,6 @@ async function updateManagedInstall(managed, force, yes, stashDir, allowDangerou
|
|
|
642
640
|
path.resolve(managed.localRoot) !== path.resolve(synced.contentDir) &&
|
|
643
641
|
managed.source !== "local" &&
|
|
644
642
|
!managed.writable;
|
|
645
|
-
if (movedRoot) {
|
|
646
|
-
const { confirmDestructive } = await import("../../cli/confirm.js");
|
|
647
|
-
const confirmed = await confirmDestructive(`Update resolved a new content directory for "${managed.installId}" (${synced.contentDir}) and would delete the previous install directory at ${managed.localRoot}. This cannot be undone.`, { yes });
|
|
648
|
-
if (!confirmed)
|
|
649
|
-
throw new UsageError(`Update cancelled for "${managed.installId}"; no configuration was changed.`);
|
|
650
|
-
}
|
|
651
643
|
const index = await publishAndIndex(stashDir, async () => {
|
|
652
644
|
staged.publish();
|
|
653
645
|
await upsertLockEntry({
|
|
@@ -724,7 +716,6 @@ export async function akmUpdate(input) {
|
|
|
724
716
|
const target = input?.target?.trim();
|
|
725
717
|
const all = input?.all === true;
|
|
726
718
|
const force = input?.force === true;
|
|
727
|
-
const yes = input?.yes === true;
|
|
728
719
|
const allowDangerousEnvKeys = input?.allowDangerousEnvKeys === true;
|
|
729
720
|
const config = loadConfig();
|
|
730
721
|
const managedInstalls = listManagedInstalls(config);
|
|
@@ -732,7 +723,7 @@ export async function akmUpdate(input) {
|
|
|
732
723
|
// Registry-managed install (lock-backed) — re-download from its locator.
|
|
733
724
|
const managed = resolveManagedTarget(config, target);
|
|
734
725
|
if (managed) {
|
|
735
|
-
const updated = await updateManagedInstall(managed, force,
|
|
726
|
+
const updated = await updateManagedInstall(managed, force, stashDir, allowDangerousEnvKeys);
|
|
736
727
|
return buildUpdateResponse(stashDir, target, all, [updated.item], { index: updated.index });
|
|
737
728
|
}
|
|
738
729
|
// Plain git / website source (bundles without a lock) — provider re-sync.
|
|
@@ -789,7 +780,7 @@ export async function akmUpdate(input) {
|
|
|
789
780
|
return false;
|
|
790
781
|
});
|
|
791
782
|
if (npmMatch) {
|
|
792
|
-
const updated = await updateManagedInstall(managedInstallViewOfPlainNpm(config, npmMatch), force,
|
|
783
|
+
const updated = await updateManagedInstall(managedInstallViewOfPlainNpm(config, npmMatch), force, stashDir, allowDangerousEnvKeys);
|
|
793
784
|
return buildUpdateResponse(stashDir, target, all, [updated.item], { index: updated.index });
|
|
794
785
|
}
|
|
795
786
|
const filesystemMatch = stashes.find((source) => {
|
|
@@ -812,7 +803,7 @@ export async function akmUpdate(input) {
|
|
|
812
803
|
let latestIndex;
|
|
813
804
|
for (const managed of selected) {
|
|
814
805
|
try {
|
|
815
|
-
const updated = await updateManagedInstall(managed, force,
|
|
806
|
+
const updated = await updateManagedInstall(managed, force, stashDir, allowDangerousEnvKeys);
|
|
816
807
|
processed.push(updated.item);
|
|
817
808
|
latestIndex = updated.index;
|
|
818
809
|
}
|
|
@@ -842,7 +833,7 @@ export async function akmUpdate(input) {
|
|
|
842
833
|
latestIndex = updated.index;
|
|
843
834
|
}
|
|
844
835
|
else if (plain.type === "npm") {
|
|
845
|
-
const updated = await updateManagedInstall(managedInstallViewOfPlainNpm(config, plain), force,
|
|
836
|
+
const updated = await updateManagedInstall(managedInstallViewOfPlainNpm(config, plain), force, stashDir, allowDangerousEnvKeys);
|
|
846
837
|
processed.push(updated.item);
|
|
847
838
|
latestIndex = updated.index;
|
|
848
839
|
}
|
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");
|