akm-cli 0.10.261007-beta → 0.10.261009-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 (43) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/STABILITY.md +10 -4
  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/commands/health.js +1 -2
  10. package/dist/commands/improve/consolidate/pair-pass.js +59 -31
  11. package/dist/commands/improve/extract.js +18 -12
  12. package/dist/commands/improve/memory/memory-belief.js +2 -2
  13. package/dist/commands/proposal/repository.js +44 -13
  14. package/dist/commands/read/curate.js +77 -18
  15. package/dist/commands/read/search-cli.js +25 -11
  16. package/dist/commands/read/search.js +3 -3
  17. package/dist/commands/read/show.js +47 -1
  18. package/dist/core/adapter/adapters/akm-adapter.js +1 -0
  19. package/dist/core/adapter/adapters/llm-wiki-adapter.js +3 -5
  20. package/dist/core/adapter/adapters/okf-adapter.js +3 -16
  21. package/dist/core/asset/markdown-link-targets.js +43 -0
  22. package/dist/core/common.js +17 -2
  23. package/dist/core/config/config-io.js +14 -4
  24. package/dist/indexer/passes/metadata.js +17 -0
  25. package/dist/indexer/scan/doc-to-entry.js +1 -0
  26. package/dist/indexer/search/db-search.js +16 -7
  27. package/dist/indexer/search/search-hit-enrichers.js +3 -3
  28. package/dist/output/shapes/curate.js +3 -1
  29. package/dist/output/shapes/helpers.js +6 -0
  30. package/dist/output/text/show-format.js +7 -0
  31. package/dist/scripts/akm-migrate-node.js +1824 -324
  32. package/dist/scripts/akm-migrate.js +1824 -324
  33. package/dist/storage/repositories/index-entries-repository.js +16 -0
  34. package/dist/storage/repositories/index-fts-repository.js +28 -6
  35. package/dist/storage/repositories/index-vec-repository.js +19 -3
  36. package/docs/architecture/0.10-process-rules.md +4 -4
  37. package/docs/architecture/architecture.md +4 -0
  38. package/docs/architecture/persisted-data-compat.md +1 -1
  39. package/docs/reference/api.md +19 -1
  40. package/docs/reference/cli.md +56 -4
  41. package/docs/reference/data-and-telemetry.md +1 -1
  42. package/docs/reference/memory.md +7 -6
  43. package/package.json +2 -1
@@ -573,6 +573,22 @@ export function getEntryCountByType(db) {
573
573
  export function getEmbeddableEntryCount(db) {
574
574
  return getEntryCount(db);
575
575
  }
576
+ /** Read one bounded candidate batch using the same mapper as browse. */
577
+ export function getEntriesByIds(db, ids) {
578
+ const entries = [];
579
+ for (let offset = 0; offset < ids.length; offset += SQLITE_CHUNK_SIZE) {
580
+ const chunk = ids.slice(offset, offset + SQLITE_CHUNK_SIZE);
581
+ const rows = db
582
+ .prepare(`SELECT ${ENTRY_COLUMNS} FROM entries WHERE id IN (${chunk.map(() => "?").join(",")})`)
583
+ .all(...chunk);
584
+ for (const row of rows) {
585
+ const entry = rowToIndexedEntry(row, "getEntriesByIds");
586
+ if (entry)
587
+ entries.push(entry);
588
+ }
589
+ }
590
+ return entries;
591
+ }
576
592
  export function getEntryById(db, id) {
577
593
  const row = db
578
594
  .prepare("SELECT file_path, document_json, item_ref, bundle_id, component_id, concept_id, adapter_id FROM entries WHERE id = ?")
@@ -104,7 +104,7 @@ const BM25_COLUMN_WEIGHTS = "0, 1.0, 1.0, 1.0, 1.0, 1.0";
104
104
  * `entryType` narrows to one type; otherwise `excludeTypes` (#627, the
105
105
  * default-hidden types such as `session`) are left out.
106
106
  */
107
- export function searchFts(db, query, limit, entryType, excludeTypes = []) {
107
+ export function searchFts(db, query, limit, entryType, excludeTypes = [], filterCandidates) {
108
108
  const tokens = ftsQueryTokens(query);
109
109
  if (limit <= 0 || tokens.length === 0)
110
110
  return [];
@@ -114,17 +114,39 @@ export function searchFts(db, query, limit, entryType, excludeTypes = []) {
114
114
  : excludeTypes.length > 0
115
115
  ? `AND e.type NOT IN (${excludeTypes.map(() => "?").join(", ")})`
116
116
  : "";
117
- const params = [ftsOrMatch(tokens), ...(typed ? [entryType] : excludeTypes), limit];
117
+ const params = [ftsOrMatch(tokens), ...(typed ? [entryType] : excludeTypes)];
118
118
  // Contentless FTS rows address their entry by rowid; the COALESCE also reads
119
119
  // the content-bearing layout older releases wrote.
120
- return db
121
- .prepare(`SELECT e.id AS id, e.item_ref AS itemRef
120
+ const statement = db.prepare(`SELECT e.id AS id, e.item_ref AS itemRef
122
121
  FROM entries_fts f
123
122
  JOIN entries e ON e.id = COALESCE(f.entry_id, f.rowid)
124
123
  WHERE entries_fts MATCH ? ${filter}
125
124
  ORDER BY bm25(entries_fts, ${BM25_COLUMN_WEIGHTS}), e.item_ref
126
- LIMIT ?`)
127
- .all(...params);
125
+ ${filterCandidates ? "" : "LIMIT ?"}`);
126
+ if (!filterCandidates)
127
+ return statement.all(...params, limit);
128
+ // Stream matching rows in BM25 order, stopping at the eligible budget.
129
+ // No full-corpus materialization and no repeated OFFSET/restarted query.
130
+ const selected = [];
131
+ let batch = [];
132
+ const keepBatch = () => {
133
+ const eligible = filterCandidates(batch.map((row) => row.id));
134
+ for (const row of batch)
135
+ if (eligible.has(row.id))
136
+ selected.push(row);
137
+ batch = [];
138
+ };
139
+ for (const row of statement.iterate(...params)) {
140
+ batch.push(row);
141
+ if (batch.length < 100)
142
+ continue;
143
+ keepBatch();
144
+ if (selected.length >= limit)
145
+ break;
146
+ }
147
+ if (selected.length < limit && batch.length > 0)
148
+ keepBatch();
149
+ return selected.slice(0, limit);
128
150
  }
129
151
  /**
130
152
  * Resolve an opaque fragment selector from the indexed safe projection, not
@@ -84,7 +84,7 @@ export function upsertEmbedding(db, entryId, embedding, model) {
84
84
  * scanned: the `k` nearest of those, not the `k` nearest of everything filtered
85
85
  * afterwards.
86
86
  */
87
- export function searchVec(db, query, k, scope) {
87
+ export function searchVec(db, query, k, scope, filterCandidates) {
88
88
  const dim = query.length;
89
89
  const q = Float64Array.from(query);
90
90
  let queryNorm = 0;
@@ -122,10 +122,26 @@ export function searchVec(db, query, k, scope) {
122
122
  }
123
123
  const order = ids.map((_, index) => index);
124
124
  order.sort((a, b) => similarities[b] - similarities[a] || ids[a] - ids[b]);
125
- return order.slice(0, k).map((index) => ({
125
+ const result = (index) => ({
126
126
  id: ids[index],
127
127
  distance: Math.sqrt(2 * Math.max(0, 1 - similarities[index])),
128
- }));
128
+ });
129
+ if (!filterCandidates)
130
+ return order.slice(0, k).map(result);
131
+ // Filter in similarity order, a batch at a time, so only the rows near the
132
+ // top are loaded and checked — not every embedded entry.
133
+ const results = [];
134
+ for (let start = 0; start < order.length && results.length < k; start += 100) {
135
+ const batch = order.slice(start, start + 100);
136
+ const eligible = filterCandidates(batch.map((index) => ids[index]));
137
+ for (const index of batch) {
138
+ if (results.length === k)
139
+ break;
140
+ if (eligible.has(ids[index]))
141
+ results.push(result(index));
142
+ }
143
+ }
144
+ return results;
129
145
  }
130
146
  /**
131
147
  * The k nearest neighbours of an already-indexed entry, by its stored vector —
@@ -617,10 +617,10 @@ Paths are relative to the repository root of this branch. `consolidate.ts` is `s
617
617
  - **Pair candidates** — 20 nearest neighbours are fetched per initiator and the first 5 that pass the filters are kept. Filters: same bundle; type memory, flat knowledge or lesson; file exists; not itself; not its own `.derived` twin or parent; cosine at or above the floor; pair deduplicated by unordered key. A `.derived` memory is not excluded as a candidate, and the guards below inspect the retired side only, so it can be the kept side. *Why:* S2: a few self/twin/bundle/tier misses in the raw top 5 starved initiators to zero candidates. (`pair-pass.ts:80-83,176-196,455-508`)
618
618
  - **Pair budget and admission** — At most 300 pairs are judged per run (`MAX_PAIRS_PER_RUN`). Whole initiators are admitted, never single pairs, first-fit. New-or-changed initiators go before any backlog initiator, then by best cosine. A group with any candidate touching an asset in a pending retire proposal (as retired or successor) is skipped before it spends budget. The retire guards run after the judge call with no pre-filter, so budget is also spent on pairs whose only retire outcome would be refused (a `.derived` twin whose parent exists, whether in a pair of twins or flipped against knowledge). *Why:* the old flat top-300 cap left initiators partially judged forever; simulation: 30 nights made 9,000 calls but completed only 455 pairs, and whole-initiator admission drains the backlog in about 12 nights. (`pair-pass.ts:94-95,944-997`)
619
619
  - **Pair ledger row** — An initiator gets a ledger row (`proposed` or `judged_no_action`, with body hash) only if all its candidates were admitted and none failed. Otherwise the next run reconsiders it. An initiator with no stored vector never gets a row, because the neighbour query could not run for it (`hasStoredEmbedding`); it is counted in the pair-pass warning, so turning embeddings on later reconsiders that backlog (fixed in this PR, #2). An initiator that was queried and has zero candidates still qualifies (`if (!hasCandidates) return true`) and is stamped `judged_no_action` with its body hash, a content-driven hold with no clock. *Why:* a row means the asset was judged against its neighbours; without a vector nothing was. (`pair-pass.ts:1057-1075`, `index-vec-repository.ts:180-183`)
620
- - **Judge labels and what each means** — Labels are `duplicate` (both claim lists empty), `subsumed` (exactly one list empty), `supersedes` (B replaces a claim in A and `onlyInA` is empty), `contradicts`, `overlap`, `unrelated`. The judge sees up to 12,000 characters per side (the cut is silent: nothing on the proposal's retirement metadata marks truncation, so a side can be retired with its tail unseen, contradicting "deletes a note only when the other keeps every claim"). Claim lists are durable claims (fact, value, command, path, number and so on). A side may be retired only if the judge listed nothing that side alone holds. *Why:* akm deletes a note only when the other keeps every claim; the lists make that safety explicit. 64% of the old judge's retirements lost nothing, against 92% with the lists. (`src/assets/prompts/consolidate-pair.md:1-19`; `pair-pass.ts:96-100,602-615`; `CHANGELOG.md:1096-1097`)
621
- - **Replacement rule (same named subject)** — B replaces A's claim only when both are about the same named thing (service, integration, partner, component, region or file) AND B gives a current new value, meaning B says so (now, instead of, since, renamed, corrected, no longer) or B is created later. Notes about different named things never replace each other, whatever their dates. A different value for the same thing with neither sign is a conflict: both values are listed and the label is `contradicts`. *Why:* #1146 — gpt-5.6-terra retired 10 of 60 notes holding a claim the survivor lacked, all from `contradicts` pairs it labelled `supersedes`, because akm called B "newer" when the dates tied. #1149 — a bare later date then made gpt-oss:120b retire notes about different named things (Alder vs Birch partners). Measured unsafe retirements went 1, 2, 1 → 0, 0, 0 on gpt-oss per case, but the pool run on gpt-oss went 0, 1, 0 → 0, 1, 1 and terra was not rerun (PR #1149). (`consolidate-pair.md:9,14-17`; #1134, #1146, #1149)
622
- - **Tied dates** — Dates come from git first-add. When they are equal, the prompt labels the sides "A (same age as B)" / "B (same age as A)" instead of older/newer. *Why:* so "B (newer)" is not read as proof that B replaces A. (`pair-pass.ts:566-583`; #1134, #1146)
623
- - **Retire outcome table** — `duplicate` and `supersedes` retire the older and keep the newer. `subsumed` retires the side the judge names `redundant` and keeps the other (`redundant: "A"` retires the older, `"B"` the newer); nothing is minted if the pointer is missing or invalid. A side is retired only when the judge listed nothing that side alone holds. `contradicts`, `overlap` and `unrelated` mint nothing (`contradicts` is only counted). The tombstone reason maps `supersedes` to `superseded`. *Why:* owner-graded calibration replaced the original "shorter body" rule; the combined retire class graded 20/22 = 0.91. (`pair-pass.ts:14-17,586-615,751-752`, `src/assets/prompts/consolidate-pair.md:13,19`)
620
+ - **Complete claim assessment** — Both the first judge and the second look receive complete stripped bodies with their existing metadata (#1195): ref, type, description and created/updated dates for the first judge; ref and description for the second look. There is no prompt-body cap. Lexical attention hints list tokens unique to each side (only X for the second look); they help locate differences but do not establish claim equivalence or replace the complete notes. The first judge returns reason, `onlyInA`, `onlyInB`, and confidence. AKM derives `duplicate` when both lists are empty, `subsumed` when exactly one is empty, and `overlap` when neither is empty, rather than asking the model to repeat the decision in relation/redundant/stale fields. A missing or malformed claim list is never treated as empty. A context-window or transport failure is a failed assessment; the client does not shorten evidence to fit. A failed first judgment mints nothing; a failed or malformed second look leaves the proposal pending and unstaged. Review and reject/reassess potentially truncated queued retirements before unattended drain: upgrading does not revalidate them. No storage migration is required.
621
+ - **Replacement rule (same named subject)** — B replaces A's claim only when both are about the same named thing (service, integration, partner, component, region or file) AND B gives a current new value, meaning B says so (now, instead of, since, renamed, corrected, no longer) or B is created later. Notes about different named things never replace each other, whatever their dates. A different value for the same thing with neither sign is a conflict: both values are listed, producing `overlap` and no retirement. *Why:* #1146 — gpt-5.6-terra retired 10 of 60 notes holding a claim the survivor lacked, all from `contradicts` pairs it labelled `supersedes`, because akm called B "newer" when the dates tied. #1149 — a bare later date then made gpt-oss:120b retire notes about different named things (Alder vs Birch partners). Measured unsafe retirements went 1, 2, 1 → 0, 0, 0 on gpt-oss per case, but the pool run on gpt-oss went 0, 1, 0 → 0, 1, 1 and terra was not rerun (PR #1149). (`consolidate-pair.md`; #1134, #1146, #1149)
622
+ - **Tied dates** — Dates come from git first-add. When they are equal, the prompt labels the sides "A (same age as B)" / "B (same age as A)" instead of older/newer. Both checks repeat the same-date rule after the bodies and hints so prompt position does not imply currency. *Why:* so "B (newer)" is not read as proof that B replaces A. (`pair-pass.ts:566-583`; #1134, #1146)
623
+ - **Retire outcome table** — A duplicate retires the older note and keeps the newer; a subsumed pair retires the side with an empty distinct-claim list. Both nonempty lists produce no retirement. An updated value that preserves the older claim is handled by the same claim-list rule and receives a `subsumed` retirement label. Existing queued `supersedes` proposals and their `superseded` tombstone reason remain supported without rewriting them. Claim-preservation, same-run chain, knowledge/memory, continuity, freshness, archive and recovery checks still run on the selected sides.
624
624
  - **Knowledge is never replaced with a memory** — If the retired side would be a knowledge note and the successor a memory (a `.derived` memory counts as a memory), the pair is flipped to retire the memory instead, but only if the judge lists no claim held only by the memory. Otherwise nothing is minted. The flipped pair then runs every guard like any retirement. *Why:* owner rule 2026-10-08: knowledge is the reviewed form and a memory is raw capture, often the memory the note was promoted from. 21 such retirements had been accepted since 2026-10-01. (`pair-pass.ts:757-763`; #1092)
625
625
  - **Retire guards** — Never retire a `.derived` memory whose parent still exists. Within a run, an asset used as a retired side or successor cannot be retired or reused again (same-run chain guard). The "never retire a `.derived` whose parent exists" reasoning is not applied to successors: `foo` can be retired in favour of `bar.derived`, and `bar` later retired against `X`, so `foo`'s claims leave the live tree with no judge having compared `bar.derived` to anything. A retire-worthy verdict dropped by the chain guard counts as failed, so its initiator gets no ledger row and is retried. A pair whose refs and both body hashes match a rejected or reverted retire proposal is skipped before the judge call, except one rejected only by expiry or as a stale target. *Why:* night 1 judged 177 duplicate verdicts into only 59 proposals, so silently abandoning the dropped ones was a bug. A reverted retirement is the same "no, not this" signal as a rejection. (`pair-pass.ts:388-411,713-726,765-789`)
626
626
  - **Continuity-risk check (forgetting safety)** — Before minting, up to 5 of the retired asset's most recent cleaned past queries are replayed through the real search with limit 10. For every query where the retired asset ranked in the top 10, the successor must also rank in the top 10. Failures, and unverified queries (the search threw, or fell back to keyword-only, including every later query once an earlier one fell back), set `continuityRisk` on the proposal. It still mints, but is excluded from all bulk accept and from drain staging. No recorded queries, or normalized-identical bodies, means no check and no risk. A flagged proposal has no recovery path: an embedding outage during the pair pass flags every retire proposal minted that night, they can only be accepted by id, no command re-runs the check (`checkRetirementContinuity` is called only from the pair pass), and reopen preserves the flag, so one bad night means about 90 individual accepts. *Why:* this is rule R3, the forgetting-safety lane's purpose moved to a per-proposal search-rank replay. An unverified query must never read as "no risk found". A dead embedding endpoint would otherwise cost about an hour (S2). (`src/commands/improve/consolidate/continuity-check.ts:5-16,26-29,135-192`; `pair-pass.ts:797-809`; `src/commands/proposal/proposal.ts:536-543`)
@@ -159,6 +159,10 @@ Notes:
159
159
  - registry results live in `registryHits`, never in `hits`
160
160
  - `--from all` keeps registry results in `registryHits` — they are not
161
161
  rank-merged with source hits
162
+ - curate carries search's normalized scope/source/type/belief controls through
163
+ selection, derived-surface enrichment, support refs and packing. Search and curate default
164
+ to current; explicit all/historical evidence is labelled as historical context.
165
+ Direct show remains a deliberate inspection path without a belief default.
162
166
  - local search and curate expose the actual execution in `searchMode`:
163
167
  `semantic`, intentional/unavailable `keyword`, or `fts-fallback` when a
164
168
  ready semantic runtime failed during the query; that degradation also
@@ -43,7 +43,7 @@ and named once, round-trips through ordinary writes, and is dropped by
43
43
  | Workflow IR (frozen plans) | `src/workflows/freeze/freeze.ts`, read back in `src/workflows/runtime/run-plan.ts` (`readRunPlan`, `decodeWorkflowPlan`) | `irVersion` (`WORKFLOW_PLAN_VERSION`, 6) on the plan and in `plan_ir_version`; `plan_hash` alongside | Every workflow compiles to one plan type. `decodeWorkflowPlan` reads the irVersion 5 shape 0.9.15 and 0.9.16 wrote (its `sourceReadSet` and host-identity fields included) as well as the current one, and keeps keys it does not know. Neither the version nor the hash gates a read: a stored plan that decodes runs whatever `irVersion` froze it, with one warning; one that cannot be decoded is abandoned by `akm workflow run` (status `failed`) with a message naming `akm workflow run <ref>` to start afresh — a status change, never an exception; `status`/`list`/`abandon` never read the plan | A newer akm's plan that still decodes runs the same way; one that does not decode is left untouched and refused with a message naming "Upgrade akm" (`readRunPlan`'s `newer`) | `tests/workflows/run-plan.test.ts`; `tests/integration/workflows/frozen-plan.test.ts` — the rehearsal builds no workflow run |
44
44
  | Native scheduler rows (crontab, launchd plists, Task Scheduler) | `src/tasks/backends/cron.ts`, `launchd.ts`, `schtasks.ts`; the invocation grammar in `src/tasks/scheduler-invocation.ts` | The row's own invocation shape: current rows set their environment inline (a cron `VAR=value` prefix, a plist `EnvironmentVariables` entry, a PowerShell `$env:` assignment) — always `AKM_BUNDLE_DIR`, the syncing shell's working stash — and carry no `--scheduler-context`; 0.9.0 – 0.9.17-alpha.6 rows carry `--scheduler-context <descriptor>` holding the same values; older rows carry neither. A cron command over 1,000 bytes runs an akm wrapper script (`sh <log dir>/.akm-cron-wrapper-…sh`) that holds the same line | Every shape is listed and parsed (`parseScheduledInvocationArgv`; a spilled cron row from its wrapper script): a row inside akm's own `# akm:task` markers from before `--scheduler-context` (#881), and a `--scheduler-context` row, which keeps firing (the CLI applies its descriptor, below) until the next plain `akm task sync` rewrites it in place — an update that keeps the row's launcher and schedule, never an add or remove. The primary bundle proves a row by the `AKM_BUNDLE_DIR` it carries, inline or in its descriptor (#846); any other bundle owns rows by name. Rows that still carry the pre-0.9.17 workflow execution-evidence marker (`# akm:workflow-evidence` in the crontab, an `akm-workflow-evidence` plist comment or PowerShell literal) are listed like any other and rewritten once without it. Plain `akm task sync` keeps an installed row's own launcher (`installOptionsFor`, `src/tasks/scheduler-sync.ts`) and recomputes its environment; `akm task sync --rebind` repoints the launcher | A row inside akm's markers, `com.akm.task.` label namespace, or `\akm\` task folder whose argv this binary cannot parse (for example one a newer akm wrote) is not listed: sync leaves it alone unless an enabled source renders a row with the same native id, which then replaces it — the row is derived state and its source is the record. A crontab whose akm markers are malformed is refused unmodified (`parseBlocks`, `cron.ts`) | Upgrade rehearsal gate (`tests/integration/upgrade-rehearsal/`: the previous release's `--scheduler-context` rows fire under the candidate, then `task sync --dry-run` shows them only as updates); `tests/tasks-sync.test.ts` (`--scheduler-context` rows and spilled rows are updates, never adds) |
45
45
  | Scheduler-context descriptor (`$DATA/tasks/context/<sha256>.json`, written by 0.9.0 – 0.9.17-alpha.6) | No longer written: a row carries its own environment (#987). Read by `src/tasks/scheduler-invocation.ts` (`readLegacySchedulerContext`) | `version: 1`; the environment key set | When a row that names one fires, the CLI reads the file as plain JSON and applies its `AKM_*_DIR` keys and, in one written before 0.9.17, `PATH`; nothing else about the file (name, mode, owner, link) is checked. Sync reads its `AKM_BUNDLE_DIR` to attribute the row. A plain `akm task sync` that reconciles the row rewrites it without the argument; a row a sync leaves as it is (its source failed, a `--bundle` sync did not cover it) keeps naming the file | A key this binary does not know is ignored. A file that is gone or is not JSON fails the run with `INVALID_CONFIG_FILE` naming `akm task sync`; sync cannot attribute such a row to the primary bundle and leaves it in place, and `akm task prune` reports it as `invalid-context` | `tests/tasks-scheduler-invocation.test.ts`; `tests/integration/scheduler-context-launcher.test.ts`; upgrade rehearsal gate (`tests/integration/upgrade-rehearsal/`) fires the previous release's rows under the candidate before and after its plain `task sync` |
46
- | Proposal `metadata_json` | `src/storage/repositories/proposals-repository.ts` | No explicit version field; presence/absence of the `changes` key | Rows from before the `changes` key existed (~89% of archived rows on real installs) are treated as a known legacy gap — decoded with an empty change list rather than thrown (`proposals-repository.ts:47-56`). 0.9.17-alpha.9 adds a consolidate pair-pass `retire` proposal's `changes[0].op: "delete"` (no `after`, already a valid `FileChangeOp`) and its `retirement` / `retiredArchive` / `promotionSource` / `promotionSourceHash` metadata keys, plus the retirement continuity check's `retirement.continuityRisk` (rule R3, item 1) — a person can accept a proposal carrying it by id even though it is excluded from bulk accept; an older reader's `validatePresentMetadata` does not enumerate these keys, so it neither inspects nor rejects them — the row decodes with them silently absent, same as any other field an older release predates. 0.9.19 adds `reviewHistory` (the rejections `akm proposal reopen` undid, oldest first, each with its gate verdict) the same way: a proposal reopened by 0.9.19 and read by an older release is an ordinary pending proposal with its history absent, and that release drops the key if it rewrites the row. The quality judge's evidence, `scores` and `judgeReason`, is added inside the existing `gateDecision` object (Unreleased): an older reader's `validatePresentMetadata` checks only the gate fields it knows and carries the object through whole, so the evidence survives a rewrite by an older release unread. An older release also counts a pending proposal's age (retention expiry, `--older-than`) from `createdAt`, so after a downgrade a reopened proposal can expire at the next `akm improve` run (a retire proposal never expires), or be swept by a scheduled `--older-than` bulk action or drain | 0.9.17-alpha.8 and earlier predate the retire proposal shape entirely — they were never taught to read a delete-primary change with no `after`. `akm proposal show` / `diff` / `drain` exit 70 on one, and drain's own nightly pre-pass failing on the first pending retire proposal it meets stops that run's auto-promotion for the whole stash, not just that one proposal. **Downgrade gate:** every pending retire proposal must be accepted or rejected (`akm proposal accept` / `reject`) before downgrading to 0.9.17-alpha.8 or earlier — once decided it is archived, out of the pending set `show`/`diff`/`drain` iterate, and no longer trips this. **Gap** otherwise (no code-level guard; this is operator guidance only). Downgrading also revives the starvation the second review round's B1 fixed forward-only: 0.9.17-alpha.8's `retrieval-scope.ts` counts a `consolidate-pair` ledger row as processed, so the pair pass's own nightly attempts crowd new material back out of every other improve lane's scope, the same way they did before B1 — measured on the real bundle, two alpha.9 nights left only 341 of 2,183 new-only assets still in scope once read under alpha.8. **Gap** here too (the ledger `source` string carries no version of its own; operator guidance only — do not downgrade across a pair-pass run without expecting this). | `tests/proposal-repository-pure.test.ts` — unit only; the rehearsal builds no proposal |
46
+ | Proposal `metadata_json` | `src/storage/repositories/proposals-repository.ts` | No explicit version field; presence/absence of the `changes` key | Rows from before the `changes` key existed (~89% of archived rows on real installs) are treated as a known legacy gap — decoded with an empty change list rather than thrown (`proposals-repository.ts:47-56`). 0.9.17-alpha.9 adds a consolidate pair-pass `retire` proposal's `changes[0].op: "delete"` (no `after`, already a valid `FileChangeOp`) and its `retirement` / `retiredArchive` / `promotionSource` / `promotionSourceHash` metadata keys, plus the retirement continuity check's `retirement.continuityRisk` (rule R3, item 1) — a person can accept a proposal carrying it by id even though it is excluded from bulk accept; an older reader's `validatePresentMetadata` does not enumerate these keys, so it neither inspects nor rejects them — the row decodes with them silently absent, same as any other field an older release predates. 0.9.19 adds `reviewHistory` (the rejections `akm proposal reopen` undid, oldest first, each with its gate verdict) the same way: a proposal reopened by 0.9.19 and read by an older release is an ordinary pending proposal with its history absent, and that release drops the key if it rewrites the row. `retirement.judgedScope` (Unreleased, #1197) optionally records the shared normalized memory scope tuple; `{}` means known unscoped and absence means unknown. Older rows still decode unchanged; acceptance refuses an unknown tuple and asks for rejection/reassessment, rather than inferring judgment from live files. Staged stale decisions use the existing stale-target rejection/ledger retry path. Older readers carry this nested object through but do not enforce it, so do not downgrade before reviewing pending retirements. No format-version bump or database migration is needed for this additive metadata. The quality judge's evidence, `scores` and `judgeReason`, is added inside the existing `gateDecision` object (Unreleased): an older reader's `validatePresentMetadata` checks only the gate fields it knows and carries the object through whole, so the evidence survives a rewrite by an older release unread. An older release also counts a pending proposal's age (retention expiry, `--older-than`) from `createdAt`, so after a downgrade a reopened proposal can expire at the next `akm improve` run (a retire proposal never expires), or be swept by a scheduled `--older-than` bulk action or drain | 0.9.17-alpha.8 and earlier predate the retire proposal shape entirely — they were never taught to read a delete-primary change with no `after`. `akm proposal show` / `diff` / `drain` exit 70 on one, and drain's own nightly pre-pass failing on the first pending retire proposal it meets stops that run's auto-promotion for the whole stash, not just that one proposal. **Downgrade gate:** every pending retire proposal must be accepted or rejected (`akm proposal accept` / `reject`) before downgrading to 0.9.17-alpha.8 or earlier — once decided it is archived, out of the pending set `show`/`diff`/`drain` iterate, and no longer trips this. **Gap** otherwise (no code-level guard; this is operator guidance only). Downgrading also revives the starvation the second review round's B1 fixed forward-only: 0.9.17-alpha.8's `retrieval-scope.ts` counts a `consolidate-pair` ledger row as processed, so the pair pass's own nightly attempts crowd new material back out of every other improve lane's scope, the same way they did before B1 — measured on the real bundle, two alpha.9 nights left only 341 of 2,183 new-only assets still in scope once read under alpha.8. **Gap** here too (the ledger `source` string carries no version of its own; operator guidance only — do not downgrade across a pair-pass run without expecting this). | `tests/proposal-repository-pure.test.ts` — unit only; the rehearsal builds no proposal |
47
47
  | `improve_ledger` rows | `src/storage/repositories/improve-ledger-repository.ts` | No version field of its own; the `content_hash` column (migration `029-improve-ledger-content-hash`) | A consolidate promotion row with no `content_hash` is held by its clock alone (`next_eligible_at`). 0.9.19 records `content_hash`, and no `next_eligible_at`, on a consolidate promotion once it is accepted or rejected, and holds the memory until its body hash differs (`isContentDrivenRow`); a promotion decided by an older release has no hash and keeps its old window (an accepted one none, a rejected one 7 days) | An older release reads a row by `next_eligible_at` alone and ignores the hash, so after a downgrade a memory whose promotion 0.9.19 decided is offered to the promote pass again at once, a rejected one sooner than the 7 days the older release would have held it; the hash stays on the row until the older release records its next attempt on that memory, which clears it. **Gap** (no code-level guard; operator guidance only) | `tests/integration/storage/improve-ledger-repository.test.ts` |
48
48
  | `task_history` metadata | `src/storage/repositories/task-history-repository.ts` | `metadataVersion` (currently `2`) | An absent `metadataVersion` is treated as a legacy row (58% of rows written by prior releases) and decoded best-effort; unknown fields are dropped harmlessly rather than round-tripped (`task-history-repository.ts:61-69`) | A `metadataVersion` newer than this binary's `2` is decoded best-effort as version 2 with a warning, rather than rejected (`task-history-repository.ts:85-89`) | Upgrade rehearsal gate (`tests/integration/upgrade-rehearsal/`) |
49
49
  | Lock payloads | `src/core/file-lock.ts` | No version field; payload is a bare numeric pid string, or a JSON object carrying `pid` and optionally `launcherPid` (`extractLockIdentity`, `file-lock.ts`) | Any payload shape parses permissively: extra fields are ignored, a missing or unparseable `launcherPid` is simply absent, and a payload with no parseable `pid` probes as `stale` (`invalid_pid`, `probeLock`) and is reclaimed rather than rejected | Same permissive parse; a payload from a newer akm that adds fields is read the same way | `tests/integration/file-lock.test.ts` (describe "probeLock: launcherPid (#956)") — not the rehearsal, which does not contend locks |
@@ -18,7 +18,12 @@ const text = await curate("deploy to prod", { limit: 4, type: "skill", format: "
18
18
  ```ts
19
19
  function curate(
20
20
  query: string,
21
- options?: { limit?: number; type?: string; format?: "text" | "json" },
21
+ options?: {
22
+ limit?: number; type?: string; source?: string;
23
+ filters?: { user?: string; agent?: string; run?: string; channel?: string };
24
+ belief?: "all" | "current" | "historical";
25
+ format?: "text" | "json";
26
+ },
22
27
  ): Promise<string>;
23
28
  ```
24
29
 
@@ -28,6 +33,19 @@ function curate(
28
33
  `"json"`, as the CLI does. It runs the same code as the command (argument
29
34
  validation, search, ranking, shaping and rendering are shared, not copied), so
30
35
  the two cannot drift.
36
+ - **Recall controls.** `source` is CLI `--from` (local, registry, all, or a named
37
+ bundle); `filters` uses the same normalized scope keys and exact AND semantics
38
+ as repeated CLI `--filter key=value`. `belief` defaults to `"current"`, keeping
39
+ unflagged reference knowledge and active/asserted assets. These labels do not
40
+ prove factual truth. Explicit `"all"` and `"historical"` support investigations.
41
+ The policy also applies to derived surfaces, support refs and content reads;
42
+ historical evidence is labelled. Registry catalogs have no local scope/belief
43
+ metadata and stay separate.
44
+
45
+ ```ts
46
+ await curate("deploy to prod", { source: "team", filters: { user: "alice" } });
47
+ await curate("investigate the old deployment port", { belief: "historical" });
48
+ ```
31
49
  - **Failure.** It rejects with an `Error` whose `message` is the CLI's error
32
50
  text and whose `code` is the CLI's error code (`MISSING_REQUIRED_ARGUMENT`,
33
51
  `INVALID_FLAG_VALUE`, `INVALID_CONFIG_FILE`, ...) wherever the CLI would have
@@ -616,7 +616,7 @@ kept.
616
616
  | `--assets` | flag | `false` | Include asset-level registry results (only meaningful with `--from registry\|all`; folds in the retired `akm registry search --assets`) |
617
617
  | `--filter` | `<key>=<value>` | _(none)_ | Scope filter — repeatable. Valid keys: `user`, `agent`, `run`, `channel`. Example: `--filter user=alice --filter channel=ops`. Narrows the result set; ranking is unchanged. |
618
618
  | `--include-proposed` | flag | `false` | Include entries with `quality: "proposed"` in the result set. Default search excludes them; `generated` and `curated` quality entries are always included. Unknown quality values warn once and remain searchable. |
619
- | `--belief` | `all`, `current`, `historical` | `all` | Memory belief filter. `current` keeps active memory beliefs; `historical` keeps contradicted/superseded/archived ones. |
619
+ | `--belief` | `all`, `current`, `historical` | `current` | Belief filter on any asset. `current` keeps unflagged/active/asserted assets; `historical` keeps deprecated/contradicted/superseded/archived assets. Labels do not establish truth. |
620
620
  | `--include-sessions` | flag | `false` | Include session assets, which are excluded from default results via `config.search.defaultExcludeTypes` |
621
621
  | `--format` | `json`, `jsonl`, `yaml`, `text`, `md`, `html` | `json` | Output format |
622
622
  | `--detail` | `brief`, `normal`, `full`, `agent` | `brief` | Output verbosity level, or the `agent` projection |
@@ -669,6 +669,10 @@ ranking.
669
669
  > registries with different scoring formulas. See
670
670
  > `docs/architecture/architecture.md` for the current type-level distinction.
671
671
 
672
+ Ordinary search defaults to current evidence. Use `--belief all` to enumerate
673
+ or compare every belief state, and `--belief historical` to investigate old
674
+ records. These modes also apply to empty and prefix queries.
675
+
672
676
  ### curate
673
677
 
674
678
  Pick the assets worth loading for a task. Unlike `akm search`, curate attaches
@@ -680,13 +684,20 @@ akm curate "plan a release"
680
684
  akm curate "deploy a Bun app" --limit 3
681
685
  akm curate "review an architecture proposal" --type skill
682
686
  akm curate "learn the release workflow" --from all --format text
687
+ akm curate "deploy the service" --from team --filter user=alice --filter channel=ops
688
+ akm curate "investigate the previous deployment port" --belief historical
689
+ akm curate "compare deployment decisions" --belief all
690
+ akm curate "deploy the service" --filter user=alice --pack 4000
683
691
  ```
684
692
 
685
693
  | Flag | Values | Default | Description |
686
694
  | --- | --- | --- | --- |
687
695
  | `--type` | `skill`, `command`, `agent`, `knowledge`, `instruction`, `workflow`, `script`, `memory`, `env`, `secret`, `lesson`, `task`, `session`, `fact`, `any` | `any` | Filter curated results by asset type |
688
696
  | `--limit` | number | `4` | Maximum curated results |
689
- | `--from` | `local`, `registry`, `all` | `local` | Where to search before curating |
697
+ | `--from` | `local`, `registry`, `all`, bundle name | `local` | Where to search before curating |
698
+ | `--filter` | `<key>=<value>` | _(none)_ | Repeatable exact scope filter: user, agent, run, channel; supplied keys are AND-joined |
699
+ | `--belief` | `all`, `current`, `historical` | `current` | Current keeps unflagged and active/asserted assets; historical keeps deprecated/contradicted/superseded/archived assets |
700
+ | `--pack` | positive token budget | _(none)_ | Pack eligible local content in rank order; drop tail items before truncating the first oversized item |
690
701
 
691
702
  `akm curate` takes the top `--limit` hits of one search, in search order, and
692
703
  enriches each with a preview, run details and up to two support refs: the
@@ -700,6 +711,15 @@ commands such as `akm show <ref>` or `akm bundle add <ref>` so you can
700
711
  immediately inspect or install what it found.
701
712
  `--detail` (including `agent`) works on curate output.
702
713
  Curate preserves the underlying search's `searchMode` and warnings.
714
+ Scope, named source, type and belief controls also constrain derived surfaces,
715
+ support refs and packed content; an empty eligible pool stays empty. Missing or
716
+ newly ineligible content is omitted instead of falling back to unchecked text.
717
+ Explicit all/historical results and support refs expose `beliefState` and label
718
+ historical context; packed historical items also carry a label (including text
719
+ output). Direct `show <ref>` remains available for deliberate inspection.
720
+ Ordinary knowledge without a belief label stays usable. Belief labels are
721
+ metadata, not evidence of factual truth. Existing prose within an eligible
722
+ asset is preserved; filters select assets rather than fact-checking their body.
703
723
  Agent-shaped local items include `ref`, `path`, and `editable`, plus `editHint`
704
724
  only for read-only items. Their `followUp` remains `akm show <ref>` rather than
705
725
  being replaced by clone guidance.
@@ -787,6 +807,35 @@ them all. The field is omitted when nothing links either way. Links are read
787
807
  from frontmatter and parsed structure at index time, with no model; they do
788
808
  not affect search ranking.
789
809
 
810
+ Indexed body-link syntax depends on the bundle adapter:
811
+
812
+ | Adapter | Body links that produce `links_to` relationships |
813
+ | --- | --- |
814
+ | `okf` | Inline `[label](./target.md)` and explicit reference `[label][id]` with `[id]: ./target.md` definitions |
815
+ | `llm-wiki` | Inline Markdown links; frontmatter `xrefs:` and `sources:` retain their separate contracts |
816
+ | `akm` and other native adapters | Their declared frontmatter/structural relationships; ordinary Markdown body links do not automatically become `links_to` |
817
+
818
+ For OKF and LLM Wiki, relative paths resolve from the linking file's directory
819
+ and `/`-rooted paths from the component root. Optional titles, fragments and
820
+ queries do not change the target identity. Repeated targets collapse in first
821
+ appearance order. External schemes, protocol-relative URLs, in-page anchors,
822
+ non-`.md` targets and paths escaping the component root are excluded. Missing
823
+ supported targets stay unresolved; their diagnostics are non-blocking.
824
+
825
+ Inline backtick code and backtick/tilde fenced code (including fences inside
826
+ lists and blockquotes) produce no body-link relationships or broken-link
827
+ warnings. Reference definitions inside code cannot supply OKF links. Rendering
828
+ `[[wiki links]]` in the UI does not establish indexed support: neither adapter
829
+ indexes that syntax, and LLM Wiki does not index reference-style body links.
830
+ This is the existing indexed subset, not a full Markdown syntax parser.
831
+
832
+ After upgrading a build that indexed links inside examples, run `akm index
833
+ --full` once. It reparses unchanged files and replaces their stored relationships,
834
+ removing false outgoing links, backlinks and curated support references. Ordinary
835
+ incremental indexing updates changed/deleted entries but can retain unchanged
836
+ older parsing results. No data migration is required; rebuilding the index
837
+ under the selected build is also the recovery action after reverting the fix.
838
+
790
839
  Opaque fragment shows and `--context lead` keep `ref` as the canonical parent
791
840
  identity and add
792
841
  `selectedRef`, `parentRef`, one-based `fragmentOrdinal`, `fragmentCount`,
@@ -2193,7 +2242,8 @@ service's config; reach for `secret` when one value *is* an auth credential.
2193
2242
  Manage `.env`-backed **environment files** — a group of related **configuration**
2194
2243
  for an app or service (URLs, feature flags, and any credentials it needs),
2195
2244
  loaded together. Each `env` asset is an entire `.env` file stored under `env/`
2196
- in your bundle (mode 0600). Values may or may not be sensitive; **akm protects
2245
+ in your bundle (mode 0600; on a filesystem without POSIX permissions, such as a
2246
+ CIFS/Azure Files share, the mount options decide the mode instead). Values may or may not be sensitive; **akm protects
2197
2247
  them all the same** — key *names* are discoverable; values and comment text
2198
2248
  never appear in structured output (comments routinely contain commented-out
2199
2249
  credentials, so they are treated like values). akm does **not** manage
@@ -2353,7 +2403,9 @@ Manage **secrets** — a single sensitive value used on its own for
2353
2403
  service-account JSON. Where an [env](#env) file holds a *group* of related
2354
2404
  configuration and exposes key *names*, a secret is *one* value and its **entire
2355
2405
  file is the value**, so only the secret's *name* is ever surfaced. Each secret
2356
- is a mode-0600 file under `secrets/` in your bundle.
2406
+ is a mode-0600 file under `secrets/` in your bundle (on a filesystem without
2407
+ POSIX permissions, such as a CIFS/Azure Files share, the mount options decide
2408
+ the mode instead).
2357
2409
 
2358
2410
  This mirrors Docker's secret model (one value per file, mounted at
2359
2411
  `/run/secrets/<name>`, read at runtime, never baked into the image or env at
@@ -97,7 +97,7 @@ Everything in the cache is regenerable. It is safe to delete the entire cache di
97
97
 
98
98
  | Path | Contents | Safe to delete? |
99
99
  |---|---|---|
100
- | `config-backups/config-<timestamp>.json` | Pre-save config snapshots (5 retained; owner-only permissions — file `0600`, dir `0700`, since 08-F4) | Yes |
100
+ | `config-backups/config-<timestamp>.json` | Pre-save config snapshots (5 retained; owner-only permissions — file `0600`, dir `0700`, since 08-F4; on a filesystem without POSIX permissions, such as a CIFS/Azure Files share, the mount options decide them instead) | Yes |
101
101
  | `config-backups/config.latest.json` | Latest backup alias (owner-only `0600`) | Yes |
102
102
  | `registry/` | Downloaded registry tarballs (bundle packages from npm, GitHub, etc.) | Yes — re-downloaded on next `akm bundle add` or `akm bundle update` |
103
103
  | `registry-index/` | Legacy per-URL JSON cache (v0.7 artifact) | Yes — fully replaced by `index.db` in 0.8.0 |
@@ -55,12 +55,13 @@ supported values, from strongest to weakest authority:
55
55
  | `contradicted` | Marked as contradicted by other evidence | historical |
56
56
  | `archived` | Soft-deleted; retained for audit | historical |
57
57
 
58
- `akm search` filters via `--belief current|historical|all`:
58
+ `akm search` and `akm curate` filter via `--belief current|historical|all`:
59
59
 
60
- - `current` → `active` + `asserted`
60
+ - `current` (default) → `active` + `asserted`; an asset with no `beliefState`
61
+ counts as `active`, so ordinary skills, knowledge and notes still surface
61
62
  - `historical` → `deprecated` + `superseded` + `contradicted` + `archived`
62
- - `all` (default — no filter) → every belief state, including `archived` and
63
- `contradicted` memories, is eligible to surface.
63
+ - `all` → no filter: every belief state, including `archived` and
64
+ `contradicted`, is eligible to surface.
64
65
 
65
66
  ### Derived memories as retrieval shortcuts
66
67
 
@@ -86,8 +87,8 @@ automation keeps working — agents that want the deeper summary follow
86
87
  - Background-derived memories (those inferred from other assets by `akm
87
88
  improve`) receive `captureMode: background`.
88
89
  - A memory with no explicit `beliefState` defaults to `active`.
89
- - `akm search --belief` defaults to `all` — no filter is applied unless you
90
- pass `current` or `historical` explicitly.
90
+ - `akm search --belief` and `akm curate --belief` default to `current`; pass
91
+ `--belief all` (API `belief: "all"`) for every belief state.
91
92
 
92
93
  ## Security / persistence implications
93
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.10.261007-beta",
3
+ "version": "0.10.261009-beta",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cursor.",
6
6
  "keywords": [
@@ -124,6 +124,7 @@
124
124
  "citty": "^0.2.2",
125
125
  "dotenv": "^17.4.2",
126
126
  "fast-xml-parser": "^5.10.1",
127
+ "marked": "^18.1.1",
127
128
  "node-html-parser": "^9.0.1",
128
129
  "semver": "^7.6.0",
129
130
  "turndown": "^7.2.4",