akm-cli 0.10.261008-beta → 0.10.261010-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/STABILITY.md +25 -8
  3. package/dist/api.d.ts +6 -0
  4. package/dist/api.js +12 -1
  5. package/dist/assets/prompts/consolidate-pair-check.md +2 -0
  6. package/dist/assets/prompts/consolidate-pair-same-age.md +1 -0
  7. package/dist/assets/prompts/consolidate-pair-terms.md +6 -0
  8. package/dist/assets/prompts/consolidate-pair.md +4 -12
  9. package/dist/assets/prompts/memory-infer-system.md +1 -1
  10. package/dist/assets/prompts/memory-infer-user.md +3 -2
  11. package/dist/assets/ui/app.js +59 -2
  12. package/dist/assets/ui/asset.js +71 -6
  13. package/dist/assets/ui/bundles.js +80 -3
  14. package/dist/assets/ui/diff-view.js +1 -1
  15. package/dist/assets/ui/editor.js +103 -0
  16. package/dist/assets/ui/index.html +2 -0
  17. package/dist/assets/ui/lib.js +51 -1
  18. package/dist/assets/ui/markdown.js +86 -19
  19. package/dist/assets/ui/search.js +25 -5
  20. package/dist/assets/ui/styles.css +70 -0
  21. package/dist/commands/health/improve-metrics.js +11 -1
  22. package/dist/commands/health/md-report.js +3 -3
  23. package/dist/commands/health.js +1 -2
  24. package/dist/commands/improve/consolidate/pair-pass.js +59 -31
  25. package/dist/commands/improve/extract.js +18 -12
  26. package/dist/commands/improve/loop-stages.js +2 -2
  27. package/dist/commands/improve/memory/memory-belief.js +2 -2
  28. package/dist/commands/proposal/repository.js +44 -13
  29. package/dist/commands/read/curate.js +78 -19
  30. package/dist/commands/read/knowledge.js +109 -77
  31. package/dist/commands/read/search-cli.js +25 -11
  32. package/dist/commands/read/search.js +3 -3
  33. package/dist/commands/read/show.js +47 -1
  34. package/dist/commands/sources/bundle-cli.js +4 -6
  35. package/dist/commands/sources/installed-stashes.js +10 -19
  36. package/dist/commands/ui/api.js +95 -5
  37. package/dist/commands/ui/document-source.js +51 -0
  38. package/dist/commands/ui/git-read.js +18 -2
  39. package/dist/commands/ui/proposal-actions.js +5 -3
  40. package/dist/commands/ui/server.js +25 -13
  41. package/dist/commands/ui/ui-cli.js +3 -3
  42. package/dist/commands/ui/workspace-actions.js +264 -0
  43. package/dist/commands/workflow-cli.js +3 -1
  44. package/dist/core/adapter/adapters/akm-adapter.js +1 -0
  45. package/dist/core/adapter/adapters/llm-wiki-adapter.js +3 -5
  46. package/dist/core/adapter/adapters/okf-adapter.js +3 -16
  47. package/dist/core/asset/akm-markdown.js +19 -3
  48. package/dist/core/asset/markdown-link-targets.js +43 -0
  49. package/dist/core/asset/markdown.js +14 -6
  50. package/dist/core/config/config.js +6 -4
  51. package/dist/core/non-task-input.js +67 -3
  52. package/dist/core/write-source.js +2 -2
  53. package/dist/indexer/index-writer-lock.js +5 -1
  54. package/dist/indexer/passes/memory-inference.js +28 -6
  55. package/dist/indexer/passes/metadata.js +17 -0
  56. package/dist/indexer/scan/doc-to-entry.js +1 -0
  57. package/dist/indexer/search/db-search.js +16 -7
  58. package/dist/indexer/search/search-hit-enrichers.js +3 -3
  59. package/dist/llm/memory-infer.js +23 -14
  60. package/dist/output/shapes/curate.js +3 -1
  61. package/dist/output/shapes/helpers.js +6 -0
  62. package/dist/output/text/show-format.js +7 -0
  63. package/dist/scripts/akm-migrate-node.js +1820 -329
  64. package/dist/scripts/akm-migrate.js +1820 -329
  65. package/dist/storage/repositories/index-entries-repository.js +16 -0
  66. package/dist/storage/repositories/index-fts-repository.js +28 -6
  67. package/dist/storage/repositories/index-vec-repository.js +19 -3
  68. package/dist/workflows/exec/environment.js +4 -8
  69. package/dist/workflows/parser.js +13 -4
  70. package/docs/architecture/0.10-process-rules.md +9 -7
  71. package/docs/architecture/architecture.md +4 -0
  72. package/docs/architecture/internals/storage-locations.md +1 -1
  73. package/docs/architecture/persisted-data-compat.md +1 -1
  74. package/docs/guides/author-workflows.md +78 -28
  75. package/docs/guides/bundles.md +16 -2
  76. package/docs/reference/api.md +19 -1
  77. package/docs/reference/cli.md +90 -18
  78. package/docs/reference/env-and-secrets.md +11 -0
  79. package/docs/reference/memory.md +7 -6
  80. package/docs/reference/ui-api.md +84 -8
  81. package/docs/reference/workflow-schema.md +63 -32
  82. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,103 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.261010] - 2026-10-21
10
+
11
+ ### Added
12
+
13
+ - `akm ui` gains explicit write actions beside proposal decisions (#1217): edit
14
+ an indexed, writable knowledge or memory document's full Markdown source,
15
+ create knowledge, submit feedback, add, remove and update bundles, change
16
+ bundle settings, and view a document's Git file history. Each action reuses the
17
+ CLI operation and its validation, indexing and Git effects. A save is checked
18
+ against the source's raw-content revision and refused with `409
19
+ ASSET_CONTENT_CHANGED` if the file changed. Saves and creates answer `409
20
+ ASSET_MUTATION_LEASE_HELD` at once while another writer holds the lease, and
21
+ accept bodies up to 8 MiB (other writes stay at 64 KiB). Endpoints and status
22
+ codes are in `docs/reference/ui-api.md`.
23
+
24
+ ### Changed
25
+
26
+ - The shared Markdown writer behind `akm import` and `akm remember` now takes the
27
+ asset-mutation lease, so it waits for other akm writers instead of racing them
28
+ (#1217). It also accepts an optional expected content hash, which the UI's
29
+ editor uses to refuse a stale save. CLI writes without a hash behave as before.
30
+
31
+ - Curate withholds context for exact whole-message phrases such as `hello`,
32
+ `thanks, that worked!`, `ok sounds good, go ahead`, `yes`, and `lgtm` (#1201).
33
+ Matching normalizes case, whitespace and common punctuation; added task text
34
+ still retrieves. Use `akm search` for literal lookups that collide with the list.
35
+ - Memory inference accepts an explicit, cached "no change" decision (#1208). A
36
+ tool-count or checkpoint note, or a note already adequate as written, no longer
37
+ needs a derived memory or a model call on every run. The decision is cached on
38
+ the note's body hash (cache namespace `memory-inference-v3`; older rows are a
39
+ disposable miss), writes no child, and does not stamp the parent
40
+ `inferenceProcessed`, so an edited note is considered again. It is counted in `memoryInference.noChange` in the improve
41
+ result, progress line and health metrics (the health runs table gains a
42
+ `nochange` column), never in `skippedNoFacts`. Malformed or HTML replies,
43
+ timeouts, aborts and endpoint failures are still not cached and are retried.
44
+
45
+ ## [0.10.261009] - 2026-10-21
46
+
47
+ ### Changed
48
+
49
+ - Search and curate default to `--belief current`, retaining unflagged reference
50
+ knowledge and active/asserted assets while excluding historical beliefs (#1199).
51
+ Use `--belief all` for broad investigation or `--belief historical` for old
52
+ evidence. Curate accepts search's repeatable `--filter` scope controls, and the
53
+ public `akm-cli/api` options add `source`, normalized `filters`, and `belief`.
54
+ The same eligibility follows derived surfaces, linked support and packed
55
+ content; historical results are labelled. Belief labels do not establish factual
56
+ truth. No migration or reindex is required; direct show remains available. The default
57
+ also applies to `akm ui` search, `akm remember --show-similar` and the related assets
58
+ distill reads.
59
+
60
+ ### Fixed
61
+
62
+ - Search applies eligibility filters (scope, source, type, belief state, proposed quality)
63
+ before each channel's candidate limit instead of after it (#1198). An eligible asset ranked
64
+ below many ineligible ones in the keyword or semantic channel is no longer dropped before
65
+ filtering. The vector channel checks eligibility in similarity order, so unfiltered searches
66
+ load only the top candidates. No index migration or reindex is needed.
67
+
68
+ - Extraction proposals retain captured `evidence`, optional guidance and known session sources
69
+ in the Markdown actually published by acceptance (#1196). Reindexing and full/agent `show` preserve
70
+ the support; compact discovery continues to provide refs for follow-up. Earlier pending
71
+ proposals require explicit review/repair of their separate evidence payload; existing accepted
72
+ notes are not backfilled. Run `akm index --full` to project evidence already present in old files.
73
+ - Invalid health comparison windows are rejected before runtime probes can prepend an unrelated
74
+ installed-agent advisory to the JSON usage error.
75
+
76
+ - OKF and LLM Wiki indexing no longer records relationships from links inside code spans,
77
+ fenced or indented code, including code nested in lists and blockquotes (#1200). Links are
78
+ read with the `marked` tokenizer (new runtime dependency) instead of raw regex scans; the
79
+ indexed link syntax is otherwise unchanged. Run `akm index --full` once to drop false
80
+ relationships stored by earlier builds; incremental indexing keeps unchanged files' old links.
81
+
82
+ - Consolidation preserves the existing `scope_user`, `scope_agent`, `scope_run`, and
83
+ `scope_channel` tuple (#1197). Pairing requires exact normalized equality before
84
+ consuming an eligible neighbor slot. Retirement acceptance, including resume, checks
85
+ live scopes against the judged tuple; promotion refuses incompatible source/output
86
+ scopes and preserves differently scoped derived twins. Older retirements without a
87
+ recorded judged tuple remain readable but require rejection/reassessment before
88
+ acceptance. Staged stale decisions use the existing stale-target rejection/retry
89
+ path. No database migration is needed; review pending work before unattended drain.
90
+
91
+ - Consolidation assesses complete note bodies in both retirement checks instead of silently
92
+ cutting each at 12,000 characters. Requests exceeding the selected engine's context window fail conservatively;
93
+ failed second checks leave proposals pending and unstaged. Before unattended proposal drain,
94
+ review and reject/reassess pending or staged retirements produced by older builds when either
95
+ body exceeded the former cutoff. An upgrade does not revalidate those decisions. No storage
96
+ migration is needed; accepted retirements remain recoverable with `akm proposal revert <id>`.
97
+
98
+ ### Changed
99
+
100
+ - The consolidation pair judge reports claim differences; AKM derives `duplicate`, `subsumed`,
101
+ or `overlap` and the redundant side from those lists. It no longer asks the model to repeat
102
+ that decision in relation/pointer fields. Replacement retirements use `subsumed`; historical
103
+ queued labels remain readable. Lexical attention hints accompany complete bodies and never
104
+ substitute for assessing their meaning.
105
+
9
106
  ## [0.10.261008] - 2026-10-20
10
107
 
11
108
  ### Fixed
package/STABILITY.md CHANGED
@@ -88,7 +88,7 @@ enumeration of the whole `proposal` noun group.
88
88
  | `akm index` | Stable | |
89
89
  | `akm health` | Evolving | Exit codes are Evolving; report *content* and rendered `md`/`html` layout are Experimental — do not script against report layout. |
90
90
  | `akm metrics` | Experimental | New in 0.9.28. Report content, JSON shape and the `html` dashboard may change. |
91
- | `akm ui` | Experimental | New in 0.10. The command, its flags, the page and the `/api/` JSON it reads may change in any release. Read-only except that proposals can be accepted (singly or in a batch) and rejected from it. |
91
+ | `akm ui` | Experimental | New in 0.10. The command, its flags, the page and the `/api/` JSON it reads may change in any release. Browsing is read-only; explicit actions support proposals, feedback, bundle configuration and Markdown authoring. |
92
92
  | `akm info` | Stable | |
93
93
  | `akm bundle create` | Stable | |
94
94
  | `akm bundle add` | Stable | |
@@ -401,14 +401,25 @@ for scripted use.
401
401
  step that runs another workflow and returns its last step's output) are the
402
402
  least settled part and are kept only in that simple form; `akm workflow list
403
403
  --children` goes with them.
404
+
405
+ Per-key status today, from the Outcome section of
406
+ `docs/plans/0.10-workflows.md`. It stands until the promote-or-remove
407
+ decision in [#1096](https://github.com/itlackey/akm/issues/1096); every key
408
+ is still Experimental until then.
409
+
410
+ | Key | Status |
411
+ | --- | --- |
412
+ | `params`, `steps`, `inputs`, `output` / `unit.output`, `defaults.engine`, `### gate` | Core: the feature set pinned by `core-feature-set.test.ts` |
413
+ | exec units (`unit.exec`), `budget`, `unit.retry`, `map`, `gate.max_loops` | Kept in the 0.10 trim |
414
+ | child workflows (`unit.workflow`, `unit.with`) | Experimental: least settled |
404
415
  - **`akm metrics`** — new in 0.9.28. What it reports, its JSON shape and the
405
416
  `--format html` dashboard may change in any release; do not script against
406
417
  them.
407
418
  - **`akm ui`** — new in 0.10. A local web UI over the same data
408
419
  `akm health`, `akm metrics`, `akm config`, `akm proposal`, `akm search` and
409
- `akm show` report. It is read-only except that proposals can be accepted
410
- (singly or several at once, one commit) and rejected from it, through the
411
- same functions `akm proposal accept` / `reject` call. The command's flags, the page, and the `/api/` JSON the
420
+ `akm show` report. Browsing is read-only. Explicit feedback, bundle/configuration,
421
+ knowledge-creation, revision-checked knowledge/memory editing and proposal-decision actions reuse existing AKM operations.
422
+ Historical restore remains unavailable. The command's flags, the page, and the `/api/` JSON the
412
423
  page reads (documented in `docs/reference/ui-api.md`) may change in any
413
424
  release; do not script against the API. The CLI's JSON output remains the
414
425
  scriptable surface.
@@ -471,10 +482,16 @@ lane (#1143): the `default` strategy runs it without the opt-in, bounded by
471
482
  `akm proposal drain --promote`. Only a promote that turns the judgment tier on
472
483
  needs `experimental.improveAutonomy`.
473
484
 
474
- Consolidation remains enabled with autonomy off: both its passes (promotion,
475
- and the pair pass's duplicate/subsumed/supersedes judging) only ever emit a
476
- reviewable proposal, and a pair-pass `retire` proposal is never auto-accepted
477
- by `triage` `applyMode: "promote"` regardless of this gate.
485
+ Consolidation remains enabled with autonomy off. Both passes emit proposals;
486
+ the pair pass stages a retirement when its claim checks pass and there is no
487
+ continuity risk. `triage` with `applyMode: "promote"` can accept that staged
488
+ retirement unattended; an unstaged retirement stays pending. Both retirement
489
+ checks receive complete note bodies with lexical attention hints, so the selected
490
+ engine must accommodate them. The pair judge supplies distinct-claim lists; AKM
491
+ derives duplicate/subsumed/overlap outcomes and retirement direction from those
492
+ lists. Historical queued retirement labels remain readable. Before unattended drain after an upgrade, review pending/staged
493
+ retirements produced by older builds from notes over 12,000 body characters:
494
+ upgrading does not revalidate their evidence (#1195).
478
495
 
479
496
  Because the gate is applied before the LLM preflight, a review-first workspace
480
497
  also needs fewer engines configured: a strategy whose only model-backed process
package/dist/api.d.ts CHANGED
@@ -10,6 +10,12 @@ export interface CurateOptions {
10
10
  limit?: number;
11
11
  /** Asset type filter, same as `--type`. */
12
12
  type?: string;
13
+ /** Named bundle or local|registry|all, same as --from. */
14
+ source?: string;
15
+ /** AND-joined exact scope values, same as repeated --filter. */
16
+ filters?: { user?: string; agent?: string; run?: string; channel?: string };
17
+ /** Default current; labels do not establish factual truth. */
18
+ belief?: "all" | "current" | "historical";
13
19
  /** Output format, same as `--format`. Default `"json"`, as in the CLI. */
14
20
  format?: "text" | "json";
15
21
  }
package/dist/api.js CHANGED
@@ -35,7 +35,18 @@ export async function curate(query, options = {}) {
35
35
  const mode = resolveOutputMode(["--detail", "agent", "--format", format], loadConfig().output ?? {});
36
36
  const disposeLlmUsageSink = installLlmUsagePersistenceIfAbsent();
37
37
  try {
38
- const curated = await runCurate({ query, type: options.type, limit: options.limit === undefined ? undefined : String(options.limit) }, mode);
38
+ const curated = await runCurate({
39
+ query,
40
+ type: options.type,
41
+ limit: options.limit === undefined ? undefined : String(options.limit),
42
+ from: options.source,
43
+ belief: options.belief,
44
+ filters: options.filters === undefined
45
+ ? undefined
46
+ : Object.entries(options.filters)
47
+ .filter(([, value]) => value !== undefined)
48
+ .map(([key, value]) => `${key}=${value}`),
49
+ }, mode);
39
50
  return stdoutText(renderOutput("curate", curated, { ...mode, format }));
40
51
  }
41
52
  finally {
@@ -2,4 +2,6 @@ You check whether deleting note X from one person's agent-memory library would l
2
2
 
3
3
  A durable claim is a fact, decision, value, command, flag, path, file name, URL, number, version, name, error message, condition or rule someone would act on. Ignore wording, headings, dates of writing, tags and boilerplate. A claim is stated when Y says it in any wording or format, or replaces it with a newer value. It is not stated when Y is vaguer (a config file instead of the specific path) or drops a number, command, step or condition. Read X's description as part of X. Write each item as a short phrase that quotes its identifier. Use [] when Y states everything.
4
4
 
5
+ Check the complete X, including its ending, one claim at a time against Y. A missing claim must actually be stated by X and absent from Y in meaning, not just in wording. Extra detail stated only by Y does not belong in missing. Repetition adds no claim. Before answering, remove any item that Y already states or replaces for the same named thing.
6
+
5
7
  Answer ONLY with JSON: {"missing": ["..."]}
@@ -0,0 +1 @@
1
+ The two notes have the same creation date: neither is newer, so the chronological replacement rule does not apply. Different values for the same named thing conflict unless the text explicitly says one replaces the other. Treat an unresolved conflicting value as a distinct claim. A note's position in this prompt does not establish which value is current.
@@ -0,0 +1,6 @@
1
+ Lexical attention hints from the complete notes above:
2
+ {{DIFFERENCES}}
3
+
4
+ Check these terms in their original context. They are not claim lists: different tokens can express the same claim or a replaced value, and equal token sets do not prove equal claims. Assess the complete notes, including claims that these hints do not distinguish.
5
+
6
+ Before leaving a side's claim list empty, check the source sentence of every exclusive term. Commands, flags, paths, URLs, numbers, versions and conditions must keep their specific meaning: a shared generic word does not preserve an omitted command or rule. Verify that every claim you list is absent from the other complete note in meaning.
@@ -1,6 +1,6 @@
1
- You compare two assets from one person's agent-memory library (memories and knowledge notes an AI coding agent reads). Asset A is the OLDER one and asset B the NEWER one, by the dates shown, unless both carry the same date and are labelled "same age". akm deletes one of them only when the other keeps every claim a reader would need, so your two lists decide what is safe to delete.
1
+ You compare the complete claims of two assets from one person's agent-memory library. Use the supplied dates: when both dates are equal, neither note is newer. A note can be deleted only when the other preserves every claim it states.
2
2
 
3
- First list what each asset has that the other lacks:
3
+ Compare the complete meaning of both assets before answering. Claims are equivalent when they give a reader the same facts, conditions and action, even with different wording. For example, "the build runs `make test`" and "`make test` is the build test command" are the same claim; `make test-fast` is a different command. First give a concise account of what distinguishes the claims of the two notes, including whether a value is explicitly replaced. There may be no safe deletion. Then list only claims that the other asset genuinely lacks:
4
4
  - "onlyInA": every durable claim of A that B does not state or replace.
5
5
  - "onlyInB": every durable claim of B that A does not state or replace.
6
6
 
@@ -8,14 +8,6 @@ A durable claim is a fact, decision, value, command, flag, path, file name, URL,
8
8
 
9
9
  B replaces a claim of A only when both are about the same named thing (the same service, integration, partner, component, region or file) and B gives a new value for it that is current: B says so (now, instead of, since, renamed, corrected, no longer) or B is created later than A. Notes about different named things never replace each other, whatever their dates: list each one's own name, value and path as its claims. A different value for the same thing with neither sign is a conflict, not a replacement: list A's value in "onlyInA" and B's value in "onlyInB".
10
10
 
11
- Then classify the relation as exactly one of:
12
- - "duplicate": both lists are empty.
13
- - "subsumed": exactly one list is empty. The asset with the empty list is redundant.
14
- - "supersedes": B replaces a claim in A as defined above (a newer version, a changed decision, a fixed bug, a new value), and onlyInA is empty.
15
- - "contradicts": they make logically exclusive claims about the same thing and nothing shows which one is current: B does not say it replaces the claim and was not created later.
16
- - "overlap": same subject, and both lists have items.
17
- - "unrelated": different subjects, or different named things written to one layout (two services, partners or regions), or different facts that happen to share words.
11
+ Before answering, check the final claim lists against the complete notes, including their endings. Every item in onlyInA must be stated by A and neither stated in other wording nor replaced by B; check onlyInB symmetrically. Remove equivalent or replaced claims. The reason must agree with the final lists.
18
12
 
19
- Set "redundant" to the asset that could be deleted with no loss: "A" for "duplicate", the asset with the empty list for "subsumed", "A" for "supersedes"; else null. Set "stale" to "A" when the relation is "supersedes", else null.
20
-
21
- Answer ONLY with JSON: {"onlyInA": ["..."], "onlyInB": ["..."], "relation": "...", "redundant": "A"|"B"|null, "stale": "A"|null, "confidence": 0.0-1.0, "reason": "<at most 25 words: name the asset that can be deleted and what the other asset still holds; for supersedes, quote the words of B that replace the claim or say B is created later>"}
13
+ Answer ONLY with JSON: {"reason": "<at most 25 words: the distinct claims of each side, or that their claims are equivalent; quote any explicit replacement>", "onlyInA": ["..."], "onlyInB": ["..."], "confidence": 0.0-1.0}
@@ -1 +1 @@
1
- You compress a developer memory into one high-signal derived memory for later retrieval. Return only valid JSON. No prose outside the JSON object. No markdown fences.
1
+ You compress a developer memory into one high-signal derived memory for later retrieval, or decide that it needs none. Return only valid JSON. No prose outside the JSON object. No markdown fences.
@@ -1,4 +1,5 @@
1
- Compress the memory below into one derived memory. Output ONLY a JSON object with exactly these fields: title, description, tags, searchHints, and content.
2
- Rules: title must be specific and short; description must be one sentence; tags must contain 3-8 specific tags; searchHints must contain 3-6 useful retrieval phrases; content must be a plain string with 2-3 sentences. Avoid vague generalizations, preserve key facts (names/versions/paths/config keys verbatim), and merge related points. Never return field descriptions or placeholder values.
1
+ Decide whether the memory below needs a derived memory, then output ONLY a JSON object with exactly these fields: noChange, title, description, tags, searchHints, and content.
2
+ No change: set noChange to true, title, description and content to empty strings, and tags and searchHints to empty arrays, only when a derived memory would add nothing for later retrieval: the memory is a tool-call count, a checkpoint or progress marker with no durable fact, or it is already a short, specific note that a rewrite would only restate. When in doubt, or when the memory contains any decision, cause, fix, version, path, config key or other fact worth finding later, do not use noChange.
3
+ Otherwise set noChange to false and compress the memory into one derived memory. Rules: title must be specific and short; description must be one sentence; tags must contain 3-8 specific tags; searchHints must contain 3-6 useful retrieval phrases; content must be a plain string with 2-3 sentences. Avoid vague generalizations, preserve key facts (names/versions/paths/dates/config keys verbatim), and merge related points. Never return field descriptions or placeholder values.
3
4
 
4
5
  Memory:
@@ -1,7 +1,7 @@
1
- // akm ui client: hash router and shell. Pages are modules that fetch from the read-only JSON API and return markup
1
+ // akm ui client: hash router and shell. Pages read the local JSON API and return markup
2
2
  // built with the escaping `html` tag; this file only mounts it.
3
3
 
4
- import { errorState, getMeta, html, initToken, loadingState, mount } from "./lib.js";
4
+ import { errorState, getMeta, hasUnsaved, html, initToken, loadingState, mount, setUnsaved, showFlash } from "./lib.js";
5
5
 
6
6
  initToken();
7
7
 
@@ -18,10 +18,27 @@ const ROUTES = [
18
18
  { name: "changes", label: "Changes", load: () => import("./changes.js"), render: (m, c) => m.render(c) },
19
19
  { name: "search", label: "Search", load: () => import("./search.js"), render: (m, c) => m.render(c) },
20
20
  { name: "asset", label: "Asset", nav: "search", load: () => import("./asset.js"), render: (m, c) => m.render(c) },
21
+ {
22
+ name: "edit",
23
+ label: "Edit Markdown",
24
+ nav: "search",
25
+ load: () => import("./editor.js"),
26
+ render: (m, c) => m.render(c),
27
+ },
28
+ {
29
+ name: "new",
30
+ label: "New knowledge",
31
+ nav: "search",
32
+ load: () => import("./editor.js"),
33
+ render: (m, c) => m.render(c),
34
+ },
21
35
  ];
22
36
 
23
37
  const main = document.getElementById("app");
24
38
  let sequence = 0;
39
+ let shownHash = location.hash;
40
+ let pendingHash = null;
41
+ const unsaved = document.getElementById("unsaved");
25
42
 
26
43
  function parseHash() {
27
44
  const raw = location.hash.replace(/^#/, "") || "/overview";
@@ -44,6 +61,20 @@ function markNav(active) {
44
61
  }
45
62
 
46
63
  async function route() {
64
+ if (hasUnsaved()) {
65
+ // Keep the editor's address and ask inline; "Discard changes" repeats the navigation.
66
+ pendingHash = location.hash;
67
+ if (location.hash !== shownHash) history.replaceState(null, "", location.pathname + location.search + shownHash);
68
+ mount(
69
+ unsaved,
70
+ html`<p>You have unsaved changes.</p><p class="btn-row"><button type="button" class="btn-danger" data-act="discard">Discard changes</button><button type="button" class="btn-secondary" data-act="keep">Keep editing</button></p>`,
71
+ );
72
+ unsaved.hidden = false;
73
+ unsaved.querySelector("[data-act=keep]")?.focus();
74
+ return;
75
+ }
76
+ unsaved.hidden = true;
77
+ shownHash = location.hash;
47
78
  const mine = ++sequence;
48
79
  const { name, param, query } = parseHash();
49
80
  const entry = ROUTES.find((r) => r.name === name);
@@ -64,6 +95,7 @@ async function route() {
64
95
  if (mine !== sequence) return;
65
96
  mount(main, markup);
66
97
  for (const fn of ctx.after) fn(main);
98
+ showFlash(main);
67
99
  window.scrollTo(0, 0);
68
100
  }
69
101
  } catch (error) {
@@ -87,5 +119,30 @@ async function showMeta() {
87
119
  }
88
120
 
89
121
  window.addEventListener("hashchange", route);
122
+ unsaved.addEventListener("click", (event) => {
123
+ const act = event.target.closest("button")?.dataset.act;
124
+ if (!act) return;
125
+ unsaved.hidden = true;
126
+ if (act !== "discard") return;
127
+ setUnsaved(false);
128
+ const target = pendingHash;
129
+ pendingHash = null;
130
+ // A same-hash target (Refresh view) fires no hashchange, so route directly.
131
+ if (target === null || target === location.hash) route();
132
+ else location.hash = target;
133
+ });
134
+ window.addEventListener("beforeunload", (event) => {
135
+ if (!hasUnsaved()) return;
136
+ event.preventDefault();
137
+ event.returnValue = true; // Older browsers ask only when this is set.
138
+ });
139
+ document.addEventListener("keydown", (event) => {
140
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
141
+ event.preventDefault();
142
+ if (location.hash.startsWith("#/search")) document.querySelector('input[name="q"]')?.focus();
143
+ else location.hash = "#/search";
144
+ }
145
+ });
146
+ document.querySelector("[data-refresh]")?.addEventListener("click", route);
90
147
  showMeta();
91
148
  route();
@@ -1,7 +1,20 @@
1
1
  // Asset view: metadata, frontmatter table, the body as safe markdown, links and feedback.
2
2
 
3
- import { api, assetHref, badge, definitionList, emptyState, html, pageHeader, tableWrap, when } from "./lib.js";
4
- import { markdown, splitFrontmatter } from "./markdown.js";
3
+ import { diffSection } from "./diff-view.js";
4
+ import {
5
+ actionForm,
6
+ api,
7
+ assetHref,
8
+ badge,
9
+ definitionList,
10
+ emptyState,
11
+ html,
12
+ pageHeader,
13
+ post,
14
+ tableWrap,
15
+ when,
16
+ } from "./lib.js";
17
+ import { headingId, markdown, splitFrontmatter } from "./markdown.js";
5
18
 
6
19
  function linksBlock(links) {
7
20
  const groups = Object.entries(links ?? {}).filter(([, kinds]) => kinds && typeof kinds === "object");
@@ -18,11 +31,22 @@ function linksBlock(links) {
18
31
  return html`<section><h2>Links</h2>${tableWrap(html`<table><thead><tr><th>Direction</th><th>Kind</th><th>Assets</th></tr></thead><tbody>${rows}</tbody></table>`)}</section>`;
19
32
  }
20
33
 
21
- function feedbackBlock(feedback) {
34
+ function feedbackBlock(feedback, ref, requireReason) {
22
35
  const counts = feedback?.counts ?? { positive: 0, negative: 0 };
23
36
  const history = feedback?.history ?? [];
24
37
  return html`<section><h2>Feedback</h2>
25
38
  <p><span class="num big text-ok">${counts.positive ?? 0}</span> <span class="muted">positive</span> &nbsp; <span class="num big text-fail">${counts.negative ?? 0}</span> <span class="muted">negative</span></p>
39
+ ${
40
+ ref
41
+ ? html`<form class="feedback-form form-grid">
42
+ <label>Feedback <select name="signal"><option value="positive">Helpful</option><option value="negative">Incorrect or stale</option></select></label>
43
+ <label>Reason <textarea name="reason" rows="2" placeholder="What helped, or what is wrong and should change"></textarea></label>
44
+ <p class="muted small">${requireReason ? "A reason is required for negative feedback. " : ""}A note that did not fit your task is not necessarily incorrect.</p>
45
+ <label>Tags <input name="tags" placeholder="Optional key:value tags, comma-separated (team:platform)"></label>
46
+ <button type="submit">Send feedback</button><div data-status></div>
47
+ </form>`
48
+ : ""
49
+ }
26
50
  ${
27
51
  history.length === 0
28
52
  ? emptyState("No feedback recorded for this asset.")
@@ -47,8 +71,46 @@ export async function render(ctx) {
47
71
  <div class="notice">${data.note}</div>`;
48
72
  }
49
73
  const asset = data.asset;
50
- const { fields, body } = splitFrontmatter(asset.content);
74
+ const { fields, body, startLine } = splitFrontmatter(asset.content);
75
+ const canonical = data.durableRef ?? asset.ref;
76
+ const headings = (data.headings ?? []).filter((h) => h.slug);
77
+ const historyOpen = ctx.query.get("history") === "1";
78
+ let history = "";
79
+ if (historyOpen) {
80
+ const versions = await api("/api/asset/history", { ref: canonical, revision: ctx.query.get("revision") });
81
+ const diff = versions.diff ? diffSection(versions.diff) : null;
82
+ if (diff?.wire) ctx.after.push(diff.wire);
83
+ history = html`<section><h2>Document history</h2>${
84
+ !versions.available
85
+ ? emptyState("No Git history is available for this bundle.")
86
+ : html`
87
+ ${versions.commits.length === 0 ? emptyState("No committed version of this file.") : html`<ul>${versions.commits.map((c) => html`<li><a href="${assetHref(canonical)}&history=1&revision=${c.sha}">${c.shortSha}</a> ${c.subject} <span class="muted">${when(c.time)} · ${c.author}</span></li>`)}</ul>`}
88
+ ${diff?.markup ?? ""}`
89
+ }</section>`;
90
+ }
91
+ ctx.after.push((root) => {
92
+ actionForm(root, ".feedback-form", (_form, values) =>
93
+ post("/api/feedback", {
94
+ ref: canonical,
95
+ signal: values.get("signal"),
96
+ reason: values.get("reason"),
97
+ tags: String(values.get("tags") ?? "")
98
+ .split(",")
99
+ .map((t) => t.trim())
100
+ .filter(Boolean),
101
+ }),
102
+ );
103
+ const signal = root.querySelector('[name="signal"]');
104
+ const reason = root.querySelector('[name="reason"]');
105
+ signal?.addEventListener("change", () => {
106
+ reason.required = data.feedbackRequireReason && signal.value === "negative";
107
+ });
108
+ const section = ctx.query.get("section");
109
+ // ?section= keeps the plain slug; the heading's element id is prefixed.
110
+ if (section) requestAnimationFrame(() => document.getElementById(headingId(section))?.scrollIntoView());
111
+ });
51
112
  return html`${back}${pageHeader(html`<span class="mono wrap">${asset.ref}</span>`)}
113
+ <p class="action-links"><a href="${assetHref(canonical)}&history=1">Document history</a> · <a href="#/new">New knowledge</a>${asset.editable && ["knowledge", "memory"].includes(asset.type) ? html` · <a href="#/edit?ref=${encodeURIComponent(canonical)}">Edit Markdown</a>` : ""}</p>
52
114
  <div class="badges">${badge(asset.type, "info")}${asset.origin ? badge(asset.origin, "neutral") : ""}${asset.editable ? badge("editable", "ok") : badge("read-only", "neutral")}</div>
53
115
  <section>${definitionList([
54
116
  ["Name", html`<span class="mono">${asset.name}</span>`],
@@ -61,7 +123,10 @@ export async function render(ctx) {
61
123
  ["Path", html`<span class="mono wrap">${asset.path}</span>`],
62
124
  ])}</section>
63
125
  ${fields.length > 0 ? html`<section><h2>Frontmatter</h2>${tableWrap(html`<table class="fm"><tbody>${fields.map(([k, v]) => html`<tr><th scope="row" class="mono">${k}</th><td class="mono wrap pre-wrap">${v}</td></tr>`)}</tbody></table>`)}</section>` : ""}
64
- <section><h2>Content</h2>${body.trim() === "" ? emptyState("This asset has no body.") : markdown(body)}</section>
126
+ ${headings.length > 1 ? html`<details class="contents" open><summary>On this page</summary><ul>${headings.map((h) => html`<li><a href="${assetHref(canonical)}&section=${encodeURIComponent(h.slug)}">${h.text}</a></li>`)}</ul></details>` : ""}
127
+ <section><h2>Content</h2>${body.trim() === "" ? emptyState("This asset has no body.") : markdown(body, { ref: canonical, bundle: data.bundle, links: data.links, headings: data.headings, startLine })}</section>
128
+ <details><summary>Markdown source</summary><pre class="code">${asset.content ?? ""}</pre></details>
129
+ ${history}
65
130
  ${linksBlock(asset.links)}
66
- ${feedbackBlock(data.feedback)}`;
131
+ ${feedbackBlock(data.feedback, data.durableRef, data.feedbackRequireReason)}`;
67
132
  }
@@ -1,7 +1,19 @@
1
1
  // Bundles: one card per configured bundle with git status and asset counts, plus the index state.
2
2
 
3
3
  import { proportionBar } from "./charts.js";
4
- import { api, badge, definitionList, emptyState, fmtNum, fmtPct, html, pageHeader, when } from "./lib.js";
4
+ import {
5
+ actionForm,
6
+ api,
7
+ badge,
8
+ definitionList,
9
+ emptyState,
10
+ fmtNum,
11
+ fmtPct,
12
+ html,
13
+ pageHeader,
14
+ post,
15
+ when,
16
+ } from "./lib.js";
5
17
 
6
18
  function gitBlock(git) {
7
19
  if (!git) return html`<p class="muted">Not a git repository.</p>`;
@@ -29,8 +41,10 @@ function gitBlock(git) {
29
41
  }
30
42
 
31
43
  function card(bundle) {
44
+ const writableKind = ["filesystem", "git"].includes(bundle.kind);
32
45
  return html`<article class="card">
33
- <header class="card-head"><h3>${bundle.name}</h3>${badge(bundle.kind, "info")}${bundle.default ? badge("default", "neutral") : ""}${bundle.writable ? badge("writable", "ok") : badge("read-only", "neutral")}${bundle.exists ? "" : badge("missing", "fail")}</header>
46
+ <header class="card-head"><h3>${bundle.name}</h3>${badge(bundle.kind, "info")}${bundle.default ? badge("default", "neutral") : ""}${bundle.enabled ? "" : badge("disabled", "warn")}${bundle.writable ? badge("writable", "ok") : badge("read-only", "neutral")}${bundle.exists ? "" : badge("missing", "fail")}</header>
47
+ <p><a href="#/search?bundle=${encodeURIComponent(bundle.name)}">Browse assets</a></p>
34
48
  ${definitionList([
35
49
  ["Path", bundle.path ? html`<span class="mono">${bundle.path}</span>` : html`<span class="muted">none</span>`],
36
50
  [
@@ -44,6 +58,21 @@ function card(bundle) {
44
58
  ])}
45
59
  ${proportionBar(bundle.byType ?? {})}
46
60
  <h4>Git</h4>${gitBlock(bundle.git)}
61
+ <details><summary>Bundle settings</summary>
62
+ <form class="bundle-settings form-grid" data-bundle="${bundle.name}">
63
+ <p class="muted small">Settings from ${bundle.settingsSource ?? "configuration"}. Saving creates a local override. Component settings take precedence over the bundle writable default. Choose another default before disabling the default bundle.</p>
64
+ <label class="check"><input type="checkbox" name="enabled" data-initial="${bundle.enabled}" ${bundle.enabled ? html`checked` : ""}> Enabled</label>
65
+ <label class="check"><input type="checkbox" name="writable" data-initial="${bundle.writableSetting ?? bundle.kind === "filesystem"}" ${(bundle.writableSetting ?? bundle.kind === "filesystem") ? html`checked` : ""} ${writableKind ? "" : html`disabled`}> Bundle writable default</label>
66
+ ${(bundle.components ?? []).map((c) => html`<label class="check"><input type="checkbox" name="component:${c.name}" data-initial="${c.writable ?? bundle.writableSetting ?? bundle.kind === "filesystem"}" ${(c.writable ?? bundle.writableSetting ?? bundle.kind === "filesystem") ? html`checked` : ""} ${writableKind && c.configured && !c.name.includes(".") ? "" : html`disabled`}> ${c.name} writable <span class="muted small">(${c.adapter}, ${c.root ?? "."}${c.writable === undefined ? ", inherits default" : ", explicit override"})</span></label>`)}
67
+ <button type="submit">Save settings</button><div data-status></div>
68
+ </form>
69
+ </details>
70
+ <form class="bundle-update" data-bundle="${bundle.name}"><p><button type="submit" class="btn-secondary">Update / reindex bundle</button></p><div data-status></div></form>
71
+ <details><summary>Remove bundle</summary><form class="bundle-remove form-grid" data-bundle="${bundle.name}">
72
+ <p>Uses <code>akm bundle remove</code>. Managed installed content may be deleted; filesystem sources remain on disk.</p>
73
+ <label class="check"><input type="checkbox" name="confirm" required> Remove ${bundle.name}</label>
74
+ <button type="submit" class="btn-danger">Remove bundle</button><div data-status></div>
75
+ </form></details>
47
76
  </article>`;
48
77
  }
49
78
 
@@ -72,9 +101,57 @@ function indexPanel(index) {
72
101
  </div></section>`;
73
102
  }
74
103
 
75
- export async function render() {
104
+ export async function render(ctx) {
76
105
  const data = await api("/api/bundles");
106
+ ctx.after.push((root) => {
107
+ actionForm(root, ".bundle-settings", (form, values) =>
108
+ post("/api/bundles/settings", {
109
+ bundle: form.dataset.bundle,
110
+ ...Object.fromEntries(
111
+ ["enabled", "writable"]
112
+ .filter(
113
+ (key) =>
114
+ !form.elements[key].disabled &&
115
+ form.elements[key].checked !== (form.elements[key].dataset.initial === "true"),
116
+ )
117
+ .map((key) => [key, values.has(key)]),
118
+ ),
119
+ componentWritability: Object.fromEntries(
120
+ [...form.querySelectorAll('input[name^="component:"]:not(:disabled)')]
121
+ .filter((input) => input.checked !== (input.dataset.initial === "true"))
122
+ .map((input) => [input.name.slice(10), input.checked]),
123
+ ),
124
+ }),
125
+ );
126
+ actionForm(root, ".bundle-defaults", (form, values) =>
127
+ post(
128
+ "/api/bundles/settings",
129
+ Object.fromEntries([...values].filter(([key, value]) => value !== form.elements[key].dataset.initial)),
130
+ ),
131
+ );
132
+ actionForm(root, ".bundle-add", (_form, values) =>
133
+ post("/api/bundles/add", { ...Object.fromEntries(values), writable: values.has("writable") }),
134
+ );
135
+ actionForm(root, ".bundle-update", (form) => post("/api/bundles/update", { bundle: form.dataset.bundle }));
136
+ actionForm(root, ".bundle-remove", (form, values) =>
137
+ post("/api/bundles/remove", { bundle: form.dataset.bundle, confirm: values.has("confirm") }),
138
+ );
139
+ });
140
+ const choices = (selected) =>
141
+ html`<option value="">Use fallback</option>${data.bundles.map((b) => html`<option value="${b.name}" ${b.name === selected ? html`selected` : ""}>${b.name}${b.enabled ? "" : " (disabled)"}</option>`)}`;
77
142
  return html`${pageHeader("Bundles", data.defaultBundle ? html`Default bundle <span class="mono">${data.defaultBundle}</span>${data.defaultWriteTarget ? html`, writes go to <span class="mono">${data.defaultWriteTarget}</span>` : ""}.` : "")}
143
+ <section><h2>Defaults</h2><form class="bundle-defaults form-grid card">
144
+ <label>Default bundle<select name="defaultBundle" data-initial="${data.defaultBundle ?? ""}">${choices(data.defaultBundle)}</select></label>
145
+ <label>Default write target<select name="defaultWriteTarget" data-initial="${data.defaultWriteTarget ?? ""}">${choices(data.defaultWriteTarget)}</select></label>
146
+ <button type="submit">Save defaults</button><div data-status></div>
147
+ </form></section>
78
148
  <section><h2>Configured bundles</h2>${data.bundles.length === 0 ? emptyState("No bundles are configured.", "Run akm init, or add one with akm bundle add.") : html`<div class="cards">${data.bundles.map(card)}</div>`}</section>
149
+ <section><h2>Add a bundle</h2><form class="bundle-add form-grid card">
150
+ <label>Source<input name="source" required placeholder="Local path, Git URL, npm package, or website URL"></label>
151
+ <label>Name<input name="name" placeholder="Optional bundle name"></label>
152
+ <label class="check"><input type="checkbox" name="writable"> Allow writes to a Git source (local directories are writable by default)</label>
153
+ <p class="muted small">Uses the existing source detection, validation and installation audit. Adding and updating sources can access the network.</p>
154
+ <button type="submit">Add bundle</button><div data-status></div>
155
+ </form></section>
79
156
  ${indexPanel(data.index)}`;
80
157
  }
@@ -137,7 +137,7 @@ function toolbar(mode, model) {
137
137
 
138
138
  function aligned(model, before, after, state) {
139
139
  if (model.same) {
140
- return html`<p class="muted">No line differs between the current asset and the proposal${before === after ? "" : ", only line endings or the final newline"}.</p>`;
140
+ return html`<p class="muted">No line differs between these versions${before === after ? "" : ", only line endings or the final newline"}.</p>`;
141
141
  }
142
142
  const body =
143
143
  state.mode === "inline"