akm-cli 0.10.261009-beta → 0.10.261010-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/STABILITY.md +15 -4
  3. package/dist/assets/prompts/memory-infer-system.md +1 -1
  4. package/dist/assets/prompts/memory-infer-user.md +3 -2
  5. package/dist/assets/ui/app.js +59 -2
  6. package/dist/assets/ui/asset.js +71 -6
  7. package/dist/assets/ui/bundles.js +80 -3
  8. package/dist/assets/ui/diff-view.js +1 -1
  9. package/dist/assets/ui/editor.js +103 -0
  10. package/dist/assets/ui/index.html +2 -0
  11. package/dist/assets/ui/lib.js +51 -1
  12. package/dist/assets/ui/markdown.js +86 -19
  13. package/dist/assets/ui/search.js +25 -5
  14. package/dist/assets/ui/styles.css +70 -0
  15. package/dist/commands/health/improve-metrics.js +11 -1
  16. package/dist/commands/health/md-report.js +3 -3
  17. package/dist/commands/improve/loop-stages.js +2 -2
  18. package/dist/commands/read/curate.js +1 -1
  19. package/dist/commands/read/knowledge.js +109 -77
  20. package/dist/commands/sources/bundle-cli.js +4 -6
  21. package/dist/commands/sources/installed-stashes.js +10 -19
  22. package/dist/commands/ui/api.js +95 -5
  23. package/dist/commands/ui/document-source.js +51 -0
  24. package/dist/commands/ui/git-read.js +18 -2
  25. package/dist/commands/ui/proposal-actions.js +5 -3
  26. package/dist/commands/ui/server.js +25 -13
  27. package/dist/commands/ui/ui-cli.js +3 -3
  28. package/dist/commands/ui/workspace-actions.js +264 -0
  29. package/dist/commands/workflow-cli.js +3 -1
  30. package/dist/core/asset/akm-markdown.js +19 -3
  31. package/dist/core/asset/markdown.js +14 -6
  32. package/dist/core/config/config.js +6 -4
  33. package/dist/core/non-task-input.js +67 -3
  34. package/dist/core/write-source.js +2 -2
  35. package/dist/indexer/index-writer-lock.js +5 -1
  36. package/dist/indexer/passes/memory-inference.js +28 -6
  37. package/dist/llm/memory-infer.js +23 -14
  38. package/dist/scripts/akm-migrate-node.js +18 -10
  39. package/dist/scripts/akm-migrate.js +18 -10
  40. package/dist/workflows/exec/environment.js +4 -8
  41. package/dist/workflows/parser.js +13 -4
  42. package/docs/architecture/0.10-process-rules.md +5 -3
  43. package/docs/architecture/internals/storage-locations.md +1 -1
  44. package/docs/guides/author-workflows.md +78 -28
  45. package/docs/guides/bundles.md +16 -2
  46. package/docs/reference/cli.md +39 -16
  47. package/docs/reference/env-and-secrets.md +11 -0
  48. package/docs/reference/ui-api.md +84 -8
  49. package/docs/reference/workflow-schema.md +63 -32
  50. package/package.json +1 -1
@@ -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
- const result = await writeAssetToSource(source, config, resolved.ref, options.content);
548
- const durableRef = result.ref;
549
- result.ref = resolved.displayRef;
550
- // SPEC-5 (--supersedes): demote each superseded asset by mutating its
551
- // frontmatter (`beliefState: superseded` + sorted-set-append `supersededBy`;
552
- // every other key and the body are preserved). Ordered BEFORE
553
- // commitWriteTargetBoundary so a git target batches the correction and the
554
- // demoted incumbent into the single boundary commit instead of leaving the
555
- // metadata edit as dirty residue after it.
556
- const superseded = [];
557
- const demotedByRoot = new Map();
558
- for (const item of options.supersedes ?? []) {
559
- if (!item.writable) {
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
- // A demotion failure (fs error, concurrent delete, malformed YAML the
586
- // pre-check missed) must NOT abort the correction: the new asset is
587
- // already on disk, and bailing out here would skip the boundary commit and
588
- // the write-path indexing below — leaving the correction unindexed and,
589
- // on a git target, uncommitted (and a re-run hits RESOURCE_ALREADY_EXISTS).
590
- // Degrade to the same applied:false report the non-writable path uses.
591
- try {
592
- const sameSource = path.resolve(item.stashRoot) === path.resolve(source.path);
593
- writeSupersededEdge(item.filePath, durableRef);
594
- if (sameSource) {
595
- recordWriteTargetPath(source, item.filePath);
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
- catch (error) {
599
- const reason = `demotion failed: ${error instanceof Error ? error.message : String(error)}`;
600
- warn(`Warning: superseded asset ${item.ref} was NOT demoted (${reason}). ` +
601
- "The correction was written and cites it in xrefs; demote the old asset manually or re-run the correction with --force.");
602
- superseded.push({ ref: item.ref, applied: false, reason });
603
- continue;
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
- superseded.push({ ref: item.ref, applied: true });
606
- const files = demotedByRoot.get(item.stashRoot) ?? [];
607
- files.push(item.filePath);
608
- demotedByRoot.set(item.stashRoot, files);
609
- }
610
- // 0.9.0 (issue #507): single batch commit at the write boundary for git
611
- // targets. No-op for filesystem/primary-stash targets.
612
- commitWriteTargetBoundary(target, `Update ${formatRefForMessage(resolved.ref)}`);
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
- // F1/R-058: gates ONLY the rare branch where the resolved content
143
- // directory moves and the previous `localRoot` is deleted — a normal
144
- // refresh (the overwhelming majority of updates) deletes nothing and
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: "Skip the confirmation prompt when an update needs to delete a previous install directory (because the resolved content location moved). No effect on a normal refresh, which deletes nothing.",
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
- * `yes` gates ONLY the destructive branch below (deleting a previous
615
- * `localRoot` whose content moved) — it has no effect on the sync itself. A
616
- * normal refresh that resolves the SAME content directory (the overwhelming
617
- * majority of updates) never reaches that branch, so it never prompts and
618
- * never requires `--yes` (F1/R-058: `update` must stay usable bare in
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, yes, stashDir, allowDangerousEnvKeys) {
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, yes, stashDir, allowDangerousEnvKeys);
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, yes, stashDir, allowDangerousEnvKeys);
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, yes, stashDir, allowDangerousEnvKeys);
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, yes, stashDir, allowDangerousEnvKeys);
836
+ const updated = await updateManagedInstall(managedInstallViewOfPlainNpm(config, plain), force, stashDir, allowDangerousEnvKeys);
846
837
  processed.push(updated.item);
847
838
  latestIndex = updated.index;
848
839
  }
@@ -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");