akm-cli 0.9.17-alpha.7 → 0.9.17-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,145 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.17-alpha.8] - 2026-09-28
10
+
11
+ `akm index` now records the links a bundle already declares (`xrefs`,
12
+ `supersededBy`, a `.derived` memory's parent, wiki sources, page links, and
13
+ workflow and task targets) as typed links, with no model. `akm show` lists
14
+ them, and curate's support refs come from them instead of the LLM entity
15
+ graph. The index moves to layout 26 in place on its first writable open;
16
+ 0.9.17-alpha.4 through alpha.7 cannot open it. `index.graph.enabled: false`
17
+ now stops graph extraction in `akm improve`, and a partly failed extraction is
18
+ retried. `akm migrate` converts a v2 or v3 task file to v4 in one pass, and
19
+ the launchers no longer lose a signal that arrives before their child starts.
20
+
21
+ ### Added
22
+
23
+ - **Declared links (#935).** The relations a bundle already declares are now
24
+ stored as typed links: `xrefs:` (`xref`), `supersededBy:`
25
+ (`superseded_by`), `contradictedBy:` (`contradicted_by`),
26
+ `currentBeliefRefs:` (`belief_peer`), a `.derived` memory's parent
27
+ (`derived_from`), wiki `sources:` that name an asset (`cites`), the page
28
+ links an llm-wiki or OKF bundle resolves (`links_to`), and a workflow step's
29
+ or a task's target (`uses`). `akm index` reads them from what it already
30
+ parses, with no model, so an install without an LLM engine gets them. They
31
+ live in their own table (`asset_links`), apart from the LLM entity graph:
32
+ each link belongs to the entry that declares it and is written, replaced
33
+ and deleted with it, including on incremental and write-path (`akm
34
+ remember`) indexing. A target resolves when it is indexed, not when its
35
+ citer was, so a note that cites something added later links to it without
36
+ being reindexed. Retired spellings convert in memory (`memory:<name>`,
37
+ `wiki:<wiki>/<page>`, a `.md` suffix), and, as lint already allows (#882),
38
+ a memory whose own file is gone resolves to its `.derived` child.
39
+ `akm show` lists an asset's links grouped by kind: `outgoing`, `incoming`
40
+ and the `unresolved` tokens it names, at most 10 per kind with a `total`.
41
+ `akm info` reports links per kind with how many are unresolved. Links do
42
+ not change search ranking, and `related` is unchanged: on the retrieval
43
+ suite, against 0.9.17-alpha.7 on the same index with two runs per arm,
44
+ search nDCG@10 moved +0.002 [−0.005, +0.009] (a rerun of alpha.7 alone
45
+ moved +0.006) and curate P@5 +0.000 [−0.001, +0.001].
46
+ On the retrieval snapshot of the maintainer's 21 bundles (23,979 entries)
47
+ there are 14,917 links: 7,393 `contradicted_by`, 4,401 `xref`, 2,577
48
+ `derived_from`, 540 `cites` and 6 `superseded_by`. 1,801 are unresolved;
49
+ 1,744 of those are `.derived` memories whose parent memory no longer
50
+ exists. (`src/indexer/links/declared-links.ts`,
51
+ `src/storage/repositories/index-links-repository.ts`,
52
+ `src/commands/read/show.ts`, `src/commands/sources/info.ts`)
53
+
54
+ ### Changed
55
+
56
+ - **`akm migrate` converts a v2 or v3 task file straight to v4 in one pass.**
57
+ The chain that read every file as v2, converted it to an intermediate v3
58
+ shape, then converted that to v4 (`src/tasks/source/task-to-v3.ts` ->
59
+ `task-to-v4.ts`, composed by `scripts/akm-migrate/migrate/task-files.ts`)
60
+ is now one planner: `task-to-v4.ts` reads a file once and, for v2, builds
61
+ the v3-shape record in memory — never written to disk or reported as its
62
+ own outcome — before hoisting it to v4 through the same code path a real
63
+ v3 file goes through. `akm migrate status`/`apply` output shapes, and
64
+ every blocked/changed reason code, are unchanged, checked fixture by
65
+ fixture against the prior two-hop chain's actual output (one exception:
66
+ a v3 document that fails only the typed pre-check's own "exactly one
67
+ scheduling source" rule — unreachable through the real chain, which
68
+ always ran that same pre-check first — now reports `invalid-v3-task`
69
+ instead of the raw hoist stage's own `ambiguous-scheduling-source`,
70
+ matching what `akm migrate apply` already returned end to end). The
71
+ `already-v3` and `pending-v2-to-v3-migration` intermediate states are
72
+ gone with the generation split that produced them.
73
+ `src/tasks/source/task-to-v3.ts` (500 lines) is deleted; its logic moved
74
+ into `task-to-v4.ts`, which also drops the duplicate raw-YAML reader and
75
+ outcome-base helpers the two files each carried their own copy of.
76
+ (`src/tasks/source/task-to-v4.ts`, `scripts/akm-migrate/migrate/task-files.ts`)
77
+ - **Curate's support refs come from declared links.** Each curated item's
78
+ support refs (at most two) are now assets its declared links name: what
79
+ it links to, then what links to it, in the order `akm show` lists them,
80
+ skipping assets curate already selected. They no longer come from the LLM
81
+ entity graph's `related` list. On the retrieval snapshot, `related` offered
82
+ support refs for 47 of 867 curate items (5.4%) and declared links for 257
83
+ (29.6%). The retrieval judge graded 157 of those items, asking whether each
84
+ support ref is worth opening next: 56% of declared support refs were useful
85
+ against 68% of `related`'s, so curate attaches about 24 useful support refs
86
+ per 100 items instead of 7. Curate's items are unchanged.
87
+ (`src/commands/read/curate.ts`)
88
+ - **Index layout 26.** The first writable open of an older index derives
89
+ every entry's links from its stored `document_json` in place, reading no
90
+ file: on the 23,979-entry snapshot index that takes 0.8 s and adds 3.5 MB.
91
+ Earlier layouts never stored a workflow's or a task's targets, so only the
92
+ directories holding workflows and tasks re-read on the next `akm index`.
93
+ The open leaves `index_meta.vacuumPending` like every layout migration.
94
+ 0.9.17-alpha.4 through alpha.7 refuse an index at layout 26
95
+ (`INDEX_SCHEMA_INCOMPATIBLE`, naming the upgrade), for writing as well as
96
+ reading, so going back to one of them needs a new index: move `index.db`
97
+ aside and run `akm index` under that release (the LLM graph and enrichment
98
+ cache it held are not rebuilt by `akm index`).
99
+ (`src/storage/repositories/index-schema.ts`,
100
+ `src/storage/repositories/index-entry-schema.ts`)
101
+
102
+ ### Fixed
103
+
104
+ - **`index.graph.enabled: false` stops graph extraction in `akm improve`.**
105
+ The switch was read only when improve had no strategy plan, which is never
106
+ the case in a real run, so the nightly and weekly graph tasks kept
107
+ extracting with it set. Improve now skips its graph extraction stage
108
+ whenever `index.graph.enabled` is `false`, whatever the strategy enables.
109
+ This also makes the graph ablation harness's "graph off" arm, which sets
110
+ exactly this key, turn extraction off. Improve still does not read
111
+ `index.defaults` when it picks the engine for graph extraction.
112
+ (`src/commands/improve/loop-stages.ts`)
113
+ - **Graph extraction never has more calls in flight than the run's
114
+ concurrency.** Batches run side by side up to the runner's concurrency, and
115
+ each batch also sent its per-file calls (long bodies, a non-array response)
116
+ up to that limit at once, so at a concurrency of 2 four calls could be in
117
+ flight. A batch now makes its per-file calls one at a time. At the default
118
+ concurrency of 1 nothing changes. (`src/llm/graph-extract.ts`)
119
+ - **A long document whose extraction partly failed is extracted again.** A
120
+ body over 1,600 characters is extracted in chunks. When some chunks failed
121
+ (a timeout, an error, an empty response) and others found entities, the
122
+ file was recorded and cached as extracted, so the failed chunks were never
123
+ retried. Such a file is now recorded as failed and not cached, and the next
124
+ run extracts it again, every chunk: partial failures are rare outside
125
+ provider outages, and keeping per-chunk results to skip the chunks that
126
+ succeeded would need a second cache. Until then the entities the other
127
+ chunks found are stored when the file had no graph rows yet; a file with
128
+ rows keeps them. (`src/llm/graph-extract.ts`)
129
+ - **`scripts/node-runtime/akm` could die from a raw signal instead of
130
+ forwarding it to its child.** The launcher registered its
131
+ SIGTERM/SIGINT/SIGHUP forwarding listeners only after spawning the child;
132
+ under load, a signal could arrive in that window and fall through to the
133
+ runtime's default (process-terminating) disposition, killing the launcher
134
+ before the child ever saw it. The listeners now go up before anything else
135
+ runs, with a small queue for a signal that arrives before the child exists.
136
+ - **`scripts/node-runtime/akm-migrate` carried the same pre-spawn signal
137
+ race** as `scripts/node-runtime/akm` above, for the same reason (listeners
138
+ registered only after `spawn()`), with no test covering it. Fixed the same
139
+ way, and added `tests/integration/akm-migrate-signal-forwarding.test.ts`
140
+ (modelled on `launcher-signal-forwarding.test.ts`) for its forwarding.
141
+ - **The launcher signal tests failed intermittently because of their own
142
+ fixture.** The fake child wrote its ready file before it registered its
143
+ signal handler, so a forwarded signal could reach it in between and kill it,
144
+ and the launcher then reported that signal. Each fixture now registers its
145
+ handler first. The launcher's pre-spawn window above could not cause this:
146
+ the tests signal only after the child is running.
147
+
9
148
  ## [0.9.17-alpha.7] - 2026-09-28
10
149
 
11
150
  A scheduled task is now just a command and a schedule. Each native row
package/dist/akm CHANGED
@@ -6,6 +6,48 @@
6
6
  import { spawn, spawnSync } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
 
9
+ // #956 round 3: install the signal-forwarding scaffolding before ANYTHING
10
+ // else in this process — env var setup, the bun-version probe, spawning the
11
+ // child — gets a chance to run. The three `process.once` listeners used to
12
+ // go up only after spawn() (below) returned; a scheduler preemption right
13
+ // after that syscall was enough for a signal to arrive with no listener yet,
14
+ // and the runtime's default (process-terminating) disposition killed the
15
+ // launcher outright, never reaching the child at all. `child` starts
16
+ // unset: a signal that arrives before it exists is queued and flushed the
17
+ // moment it does; if this process ends up never spawning one (the
18
+ // direct-import branch below), the queued signal is re-raised against
19
+ // ourselves once we hand default disposition back, so it behaves exactly
20
+ // like the runtime's own default rather than being silently swallowed.
21
+ let child;
22
+ let childExited = false;
23
+ const pendingSignals = [];
24
+ const forwardHandlers = new Map();
25
+ const forwardSignal = (signal) => {
26
+ if (childExited) return;
27
+ if (!child) {
28
+ pendingSignals.push(signal);
29
+ return;
30
+ }
31
+ try {
32
+ child.kill(signal);
33
+ } catch {
34
+ // Child exited in the race between the check above and here.
35
+ }
36
+ };
37
+ // `.once`, not `.on`: Node/Bun suppress a signal's default
38
+ // (process-terminating) disposition for as long as ANY listener stays
39
+ // registered for it. A persistent `.on` listener would still be registered
40
+ // when the `process.kill(process.pid, result.signal)` re-raise below runs at
41
+ // shutdown, swallowing it and leaving this launcher exiting 0 instead of
42
+ // reflecting the child's signal. `.once` consumes only the
43
+ // externally-delivered signal that triggers the forward, so the re-raise
44
+ // correctly falls through to the OS default.
45
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
46
+ const handler = () => forwardSignal(signal);
47
+ forwardHandlers.set(signal, handler);
48
+ process.once(signal, handler);
49
+ }
50
+
9
51
  // A scheduled row sets its environment itself (a cron `VAR=value` prefix, a
10
52
  // launchd plist's EnvironmentVariables), and the scheduler supplies PATH, so
11
53
  // runtime selection below needs nothing from it. A row written by 0.9.0 –
@@ -42,13 +84,21 @@ const bunEntry = fileURLToPath(new URL("./cli.js", import.meta.url));
42
84
  const nodeEntry = fileURLToPath(new URL("./cli-node.mjs", import.meta.url));
43
85
 
44
86
  if (!useBun && !process.versions.bun) {
45
- await import("./cli-node.mjs");
87
+ // No child is ever spawned on this path — the imported module runs
88
+ // in-process, so it IS the work, and the runtime's ordinary default signal
89
+ // disposition is exactly correct for it. Hand that back (the forwarding
90
+ // listeners above no longer apply here), re-raising anything that already
91
+ // arrived so it is not silently swallowed by a listener that now does
92
+ // nothing.
93
+ for (const [signal, handler] of forwardHandlers) process.removeListener(signal, handler);
94
+ if (pendingSignals.length > 0) process.kill(process.pid, pendingSignals[0]);
95
+ else await import("./cli-node.mjs");
46
96
  } else {
47
97
  const command = useBun ? (process.versions.bun ? process.execPath : "bun") : "node";
48
98
  const entry = useBun ? bunEntry : nodeEntry;
49
99
  const runtime = useBun ? "Bun" : "Node.js";
50
100
  const result = await new Promise((resolve) => {
51
- const child = spawn(command, [entry, ...process.argv.slice(2)], {
101
+ child = spawn(command, [entry, ...process.argv.slice(2)], {
52
102
  stdio: "inherit",
53
103
  env: process.env,
54
104
  // #956: give the child its OWN process group on POSIX (`setsid()` —
@@ -75,29 +125,12 @@ if (!useBun && !process.versions.bun) {
75
125
  // forward once the child has already exited: forwarding to a
76
126
  // dead/replaced pid would be at best a no-op and at worst a signal to
77
127
  // an unrelated process that reused the pid.
78
- let childExited = false;
79
128
  child.once("exit", () => {
80
129
  childExited = true;
81
130
  });
82
- const forwardSignal = (signal) => {
83
- if (childExited) return;
84
- try {
85
- child.kill(signal);
86
- } catch {
87
- // Child exited in the race between the check above and here.
88
- }
89
- };
90
- // `.once`, not `.on`: Node/Bun suppress a signal's default
91
- // (process-terminating) disposition for as long as ANY listener stays
92
- // registered for it. A persistent `.on` listener would still be
93
- // registered when the `process.kill(process.pid, result.signal)`
94
- // re-raise below runs at shutdown, swallowing it and leaving this
95
- // launcher exiting 0 instead of reflecting the child's signal. `.once`
96
- // consumes only the externally-delivered signal that triggers the
97
- // forward, so the re-raise correctly falls through to the OS default.
98
- for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
99
- process.once(signal, () => forwardSignal(signal));
100
- }
131
+ // Flush whatever arrived in the (now much smaller) window between the
132
+ // listeners going up and the child existing to receive them.
133
+ for (const signal of pendingSignals.splice(0)) forwardSignal(signal);
101
134
  child.once("error", (error) => resolve({ error }));
102
135
  child.once("exit", (code, signal) => resolve({ code, signal }));
103
136
  });
package/dist/akm-migrate CHANGED
@@ -6,6 +6,40 @@
6
6
  import { spawn, spawnSync } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
 
9
+ // #956 round 3: install the signal-forwarding scaffolding before ANYTHING
10
+ // else in this process — the Node-version check, the bun-version probe,
11
+ // spawning the child — gets a chance to run. See the matching fix and
12
+ // comment in scripts/node-runtime/akm: the three `process.once` listeners
13
+ // used to go up only after spawn() (below) returned; a scheduler preemption
14
+ // right after that syscall was enough for a signal to arrive with no
15
+ // listener yet, and the runtime's default (process-terminating) disposition
16
+ // killed the launcher outright, never reaching the child at all. `child`
17
+ // starts unset: a signal that arrives before it exists is queued and
18
+ // flushed the moment it does.
19
+ let child;
20
+ let childExited = false;
21
+ const pendingSignals = [];
22
+ const forwardSignal = (signal) => {
23
+ if (childExited) return;
24
+ if (!child) {
25
+ pendingSignals.push(signal);
26
+ return;
27
+ }
28
+ try {
29
+ child.kill(signal);
30
+ } catch {
31
+ // Child exited in the race between the check above and here.
32
+ }
33
+ };
34
+ // `.once`, not `.on`: see the matching comment in scripts/node-runtime/akm —
35
+ // a persistent `.on` listener would still be registered when the
36
+ // `process.kill(process.pid, result.signal)` re-raise below runs,
37
+ // suppressing the OS default disposition and leaving this launcher exiting
38
+ // 0 instead of reflecting the child's signal.
39
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
40
+ process.once(signal, () => forwardSignal(signal));
41
+ }
42
+
9
43
  if (!process.versions.bun) {
10
44
  const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
11
45
  if (major < 22) {
@@ -34,7 +68,7 @@ const nodeEntry = fileURLToPath(new URL("./scripts/akm-migrate-node.js", import.
34
68
  const command = process.versions.bun ? process.execPath : useBun ? "bun" : process.execPath;
35
69
  const entry = process.versions.bun || useBun ? bunEntry : nodeEntry;
36
70
  const result = await new Promise((resolve) => {
37
- const child = spawn(command, [entry, ...process.argv.slice(2)], {
71
+ child = spawn(command, [entry, ...process.argv.slice(2)], {
38
72
  stdio: "inherit",
39
73
  env,
40
74
  // #956: own process group on POSIX so the launcher's forward below is
@@ -43,27 +77,12 @@ const nodeEntry = fileURLToPath(new URL("./scripts/akm-migrate-node.js", import.
43
77
  // directly — see the matching comment in scripts/node-runtime/akm.
44
78
  detached: process.platform !== "win32",
45
79
  });
46
- let childExited = false;
47
80
  child.once("exit", () => {
48
81
  childExited = true;
49
82
  });
50
- const forwardSignal = (signal) => {
51
- if (childExited) return;
52
- try {
53
- child.kill(signal);
54
- } catch {
55
- // Child exited in the race between the check above and here.
56
- }
57
- };
58
- // `.once`, not `.on`: see the matching comment in
59
- // scripts/node-runtime/akm — a persistent `.on` listener would still be
60
- // registered when the `process.kill(process.pid, result.signal)`
61
- // re-raise below runs, suppressing the OS default disposition and
62
- // leaving this launcher exiting 0 instead of reflecting the child's
63
- // signal.
64
- for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
65
- process.once(signal, () => forwardSignal(signal));
66
- }
83
+ // Flush whatever arrived in the (now much smaller) window between the
84
+ // listeners going up and the child existing to receive them.
85
+ for (const signal of pendingSignals.splice(0)) forwardSignal(signal);
67
86
  child.once("error", (error) => resolve({ error }));
68
87
  child.once("exit", (code, signal) => resolve({ code, signal }));
69
88
  });
@@ -3,7 +3,7 @@ type: fact
3
3
  category: convention
4
4
  description: How to cross-link assets so retrieval compounds — a provenance xref when derived, sparse real associative xrefs, corrections as new assets, and canonical entity naming.
5
5
  when_to_use: Surfaced to authoring agents when they create or revise any asset that derives from, corrects, or relates to another asset.
6
- updated: 2026-07-28
6
+ updated: 2026-09-28
7
7
  ---
8
8
 
9
9
  <!--
@@ -19,8 +19,10 @@ updated: 2026-07-28
19
19
 
20
20
  Cross-references are how knowledge compounds instead of being re-derived every
21
21
  session. In AKM they are also **indexed**: the strings in an asset's `xrefs:`
22
- frontmatter fold into its search-hint text, and knowledge/memory bodies feed an
23
- LLM-extracted entity/relation graph that boosts ranking. So links are a retrieval
22
+ frontmatter fold into its search-hint text and are stored as links that
23
+ `akm show` lists on both assets (with `supersededBy:`, `contradictedBy:` and a
24
+ `.derived` memory's parent), and knowledge/memory bodies feed an LLM-extracted
25
+ entity/relation graph that boosts ranking. So links are a retrieval
24
26
  lever — which means both too few and too many hurt.
25
27
 
26
28
  ```yaml
@@ -475,15 +475,16 @@ export async function runMemoryInferenceMaintenancePass(ctx, dbCell, memoryRefsF
475
475
  export async function runGraphExtractionMaintenancePass(ctx, dbCell, args) {
476
476
  const { config, sources, primaryStashDir, resolvedPlan } = ctx;
477
477
  const settings = ctx.improveProfile?.processes?.graphExtraction;
478
- const graphEnabled = resolvedPlan ? true : isProcessEnabled("index", "graph_extraction", config);
479
478
  if (settings?.enabled === false) {
480
479
  info("[improve] graph extraction skipped (disabled by improve profile)");
481
480
  return { durationMs: 0, warnings: [] };
482
481
  }
483
482
  if (sources.length === 0)
484
483
  return { durationMs: 0, warnings: [] };
485
- if (!graphEnabled) {
486
- info("[improve] graph extraction skipped (features.index.graph_extraction is disabled)");
484
+ // `index.graph.enabled: false` turns graph extraction off everywhere,
485
+ // whatever the strategy enables.
486
+ if (!isProcessEnabled("index", "graph_extraction", config)) {
487
+ info("[improve] graph extraction skipped (index.graph.enabled is false)");
487
488
  return { durationMs: 0, warnings: [] };
488
489
  }
489
490
  const fullScan = settings?.fullScan === true;
@@ -9,12 +9,15 @@
9
9
  * needed to act (ref, run, parameters, follow-up command).
10
10
  *
11
11
  * Curation is one search with the fused ranking, the top `limit` hits, and
12
- * per-hit enrichment (preview, run and parameters, graph support refs). An
13
- * optional reranker reorders the top fused candidates first.
12
+ * per-hit enrichment (preview, run and parameters, support refs from the
13
+ * hit's declared links). An optional reranker reorders the top fused
14
+ * candidates first.
14
15
  *
15
16
  * The exported `akmCurate()` API is the single entry point; tests can also
16
17
  * drive `curateSearchResults` with a fixture search response.
17
18
  */
19
+ import { parseBundleRef } from "../../core/asset/asset-ref.js";
20
+ import { typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
18
21
  import { loadConfig } from "../../core/config/config.js";
19
22
  import { rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
20
23
  import { appendEvent } from "../../core/events.js";
@@ -212,7 +215,7 @@ async function enrichCuratedStashHit(query, hit, selectedRefs, eventSource) {
212
215
  }
213
216
  const description = shown?.description ?? hit.description;
214
217
  const preview = buildCuratedPreview(shown, hit);
215
- const supportRefs = buildCurateSupportRefs(shown?.related?.hits, selectedRefs, hit.ref);
218
+ const supportRefs = buildCurateSupportRefs(shown?.links, selectedRefs, hit.ref);
216
219
  const item = {
217
220
  source: "local",
218
221
  type: shown?.type ?? hit.type,
@@ -324,17 +327,41 @@ async function maybeRerankCuratedStashHits(query, hits) {
324
327
  function rerankDocumentTexts(hits) {
325
328
  return hits.map((hit) => [hit.name, hit.description, searchHitContent(hit)].filter(Boolean).join("\n").slice(0, RERANK_DOCUMENT_CHARS));
326
329
  }
327
- /** Up to {@link MAX_CURATE_SUPPORT_REFS} graph-related assets not already selected. */
328
- function buildCurateSupportRefs(relatedHits, selectedRefs, ownerRef) {
330
+ /**
331
+ * Up to {@link MAX_CURATE_SUPPORT_REFS} assets the hit's declared links (#935)
332
+ * name, not already selected: what the hit links to first, then what links to
333
+ * it, in the order `akm show` lists them. The LLM entity graph's `related`
334
+ * list no longer feeds them.
335
+ */
336
+ function buildCurateSupportRefs(links, selectedRefs, ownerRef) {
329
337
  const supportRefs = [];
330
- for (const hit of relatedHits ?? []) {
331
- if (!hit.ref || hit.ref === ownerRef || selectedRefs.has(hit.ref))
332
- continue;
333
- if (supportRefs.some((existing) => existing.ref === hit.ref))
334
- continue;
335
- supportRefs.push({ ref: hit.ref, type: hit.type, reason: "Related asset via shared entities." });
336
- if (supportRefs.length >= MAX_CURATE_SUPPORT_REFS)
337
- break;
338
+ for (const [part, direction] of [
339
+ ["outgoing", "from"],
340
+ ["incoming", "to"],
341
+ ]) {
342
+ for (const [kind, group] of Object.entries(links?.[part] ?? {})) {
343
+ for (const ref of group.refs) {
344
+ if (ref === ownerRef || selectedRefs.has(ref) || supportRefs.some((existing) => existing.ref === ref))
345
+ continue;
346
+ const type = supportRefType(ref);
347
+ supportRefs.push({
348
+ ref,
349
+ ...(type ? { type } : {}),
350
+ reason: `Declared link (${kind}) ${direction} this asset.`,
351
+ });
352
+ if (supportRefs.length >= MAX_CURATE_SUPPORT_REFS)
353
+ return supportRefs;
354
+ }
355
+ }
338
356
  }
339
357
  return supportRefs;
340
358
  }
359
+ /** The asset type a ref's conceptId names (`memories/x` → `memory`), when it names one. */
360
+ function supportRefType(ref) {
361
+ try {
362
+ return typeNameFromConceptId(parseBundleRef(ref).conceptId)?.type;
363
+ }
364
+ catch {
365
+ return undefined;
366
+ }
367
+ }
@@ -24,7 +24,7 @@ import { makeBundleRef, parseBundleRef } from "../../core/asset/asset-ref.js";
24
24
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
25
25
  import { extractSection, markdownFragmentSlugs } from "../../core/asset/markdown.js";
26
26
  import { buildMarkdownLeadContext, fragmentForSelector, MARKDOWN_FRAGMENT_CONTEXT_DEFAULT_MAX_CHARS, } from "../../core/asset/markdown-fragments.js";
27
- import { displayRef, typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
27
+ import { displayRef, displayRefForConceptId, typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
28
28
  import { META_DIR, parseMetaRef, readMetaFile } from "../../core/asset/stash-meta.js";
29
29
  import { asNonEmptyString, isWithin } from "../../core/common.js";
30
30
  import { loadConfig } from "../../core/config/config.js";
@@ -43,6 +43,7 @@ import { buildFileContext, buildRenderContext, getRenderer, } from "../../indexe
43
43
  import { resolveSourcesForOrigin } from "../../registry/origin-resolve.js";
44
44
  import { withIndexDb } from "../../storage/repositories/index-db.js";
45
45
  import { getIndexedMarkdownFragment } from "../../storage/repositories/index-fts-repository.js";
46
+ import { readEntryLinks } from "../../storage/repositories/index-links-repository.js";
46
47
  import { getCurrentWorkflowScopeKey } from "../../workflows/authoring/scope-key.js";
47
48
  import { buildWorkflowAction } from "../../workflows/renderer.js";
48
49
  import { getActiveWorkflowRun } from "../../workflows/runtime/runs.js";
@@ -318,12 +319,13 @@ export async function showLocal(input) {
318
319
  response.type = indexedEntry.type;
319
320
  response.name = indexedEntry.name;
320
321
  const isPrimaryStash = source?.isDefault === true;
322
+ const displayDefaultBundle = config.defaultBundle ?? (isPrimaryStash ? indexedEntry.bundleId : undefined);
321
323
  const canonicalRef = displayRef({
322
324
  type: indexedEntry.type,
323
325
  name: presentedName,
324
326
  conceptId: indexedEntry.conceptId,
325
327
  bundleId: indexedEntry.bundleId,
326
- }, config.defaultBundle ?? (isPrimaryStash ? indexedEntry.bundleId : undefined));
328
+ }, displayDefaultBundle);
327
329
  if (parsed.fragment && indexedFragment) {
328
330
  const selectedFragmentId = indexedFragment.fragments[indexedFragment.ordinal].fragmentId;
329
331
  const selectedRef = `${canonicalRef}#${selectedFragmentId}`;
@@ -395,6 +397,7 @@ export async function showLocal(input) {
395
397
  return { total: 0, hits: [] };
396
398
  }
397
399
  })(),
400
+ ...showLinks(indexedEntry.itemRef, displayDefaultBundle),
398
401
  };
399
402
  const activeRun = await getActiveWorkflowRun(getCurrentWorkflowScopeKey());
400
403
  if (activeRun) {
@@ -408,6 +411,56 @@ export async function showLocal(input) {
408
411
  }
409
412
  return fullResponse;
410
413
  }
414
+ /** Refs listed per kind of declared link; `total` still counts them all (one memory is named by 1,437 others). */
415
+ const LINKS_PER_KIND = 10;
416
+ /**
417
+ * The declared links (#935) of an indexed asset, grouped by kind: outgoing in
418
+ * authored order, incoming by ref, and unresolved tokens as authored. Empty
419
+ * parts are omitted, and the field when nothing links either way.
420
+ */
421
+ function showLinks(itemRef, defaultBundle) {
422
+ let rows;
423
+ try {
424
+ rows = withIndexDb((db) => readEntryLinks(db, itemRef));
425
+ }
426
+ catch (err) {
427
+ rethrowIfTestIsolationError(err);
428
+ rethrowIfDataDirUnreadable(err);
429
+ return {};
430
+ }
431
+ const outgoing = [];
432
+ const unresolved = [];
433
+ for (const row of rows.outgoing) {
434
+ if (row.conceptId === undefined)
435
+ unresolved.push({ kind: row.kind, ref: row.raw ?? "" });
436
+ else
437
+ outgoing.push({ kind: row.kind, ref: displayRefForConceptId(row.conceptId, row.bundleId, defaultBundle) });
438
+ }
439
+ const incoming = rows.incoming.map((row) => ({
440
+ kind: row.kind,
441
+ ref: displayRefForConceptId(row.conceptId ?? "", row.bundleId, defaultBundle),
442
+ }));
443
+ const links = {
444
+ ...groupLinks("outgoing", outgoing),
445
+ ...groupLinks("incoming", incoming),
446
+ ...groupLinks("unresolved", unresolved),
447
+ };
448
+ return Object.keys(links).length > 0 ? { links } : {};
449
+ }
450
+ function groupLinks(part, rows) {
451
+ if (rows.length === 0)
452
+ return {};
453
+ const groups = {};
454
+ for (const kind of [...new Set(rows.map((row) => row.kind))].sort())
455
+ groups[kind] = { total: 0, refs: [] };
456
+ for (const { kind, ref } of rows) {
457
+ const group = groups[kind];
458
+ group.total++;
459
+ if (group.refs.length < LINKS_PER_KIND)
460
+ group.refs.push(ref);
461
+ }
462
+ return { [part]: groups };
463
+ }
411
464
  /**
412
465
  * Warn and ignore body fragments for namespaces whose authored bytes are
413
466
  * sensitive. `warnOnce`-keyed on the exact ref: `akmShowUnified` calls this
@@ -10,6 +10,7 @@ import { formatRegistryUrl } from "../../core/registry-url.js";
10
10
  import { error } from "../../core/warn.js";
11
11
  import { closeDatabase, openExistingDatabase } from "../../storage/repositories/index-connection.js";
12
12
  import { getEntryCount, getEntryCountByType } from "../../storage/repositories/index-entries-repository.js";
13
+ import { countLinksByKind } from "../../storage/repositories/index-links-repository.js";
13
14
  import { getMeta } from "../../storage/repositories/index-meta-repository.js";
14
15
  import { pkgVersion } from "../../version.js";
15
16
  /**
@@ -99,9 +100,11 @@ function readIndexStats(resolvedPath) {
99
100
  let db;
100
101
  try {
101
102
  db = openExistingDatabase(resolvedPath);
103
+ const links = countLinksByKind(db);
102
104
  return {
103
105
  entryCount: getEntryCount(db),
104
106
  byType: getEntryCountByType(db),
107
+ ...(Object.keys(links).length > 0 ? { links } : {}),
105
108
  lastBuiltAt: getMeta(db, "builtAt") ?? null,
106
109
  hasEmbeddings: getMeta(db, "hasEmbeddings") === "1",
107
110
  };
@@ -193,6 +193,8 @@ const DOCUMENT_JSON_CARRIED_FIELDS = [
193
193
  "wikiRole",
194
194
  "sources",
195
195
  "evidenceSources",
196
+ // #935: a workflow's step targets and a task's target, read as declared links.
197
+ "uses",
196
198
  // D2 (#730): the OKF v0.2 provenance `promoteProposal` stamps onto AKM-native
197
199
  // writes (generated/verified/sources, namespaced — see `types.ts`'s
198
200
  // `OkfProvenance` doc). Carried so the akm adapter rereads what it wrote and
@@ -54,6 +54,7 @@
54
54
  * concern (Chunk 4/5), not recognition's. On the valid Chunk-0b fixture this
55
55
  * distinction never triggers.
56
56
  */
57
+ import { parseBuiltinCommandAction } from "../../../commands/command/builtin-action.js";
57
58
  import { scanEnvKeyNames } from "../../../commands/env/env.js";
58
59
  import { parseTaskSource } from "../../../tasks/source/parse-task-source.js";
59
60
  import { compileWorkflowSource, workflowStepInstructions } from "../../../workflows/compile.js";
@@ -125,6 +126,25 @@ function applyFrontmatterDescriptionAndTags(fm, out) {
125
126
  }
126
127
  }
127
128
  }
129
+ /** The command a stored `akm/command` action runs; inline content names no asset. */
130
+ function storedCommandRef(action) {
131
+ return action?.kind === "stored" ? action.ref : undefined;
132
+ }
133
+ /** The asset a workflow step targets: its `uses:` ref, or the ref of a stored `akm/command`. Prose and `run:` steps target none. */
134
+ function workflowStepTarget(spec) {
135
+ if (!spec?.uses)
136
+ return undefined;
137
+ if (spec.uses !== "akm/command")
138
+ return spec.uses;
139
+ if (spec.commandMode !== "stored-ref")
140
+ return undefined;
141
+ try {
142
+ return storedCommandRef(parseBuiltinCommandAction(spec.with));
143
+ }
144
+ catch {
145
+ return undefined;
146
+ }
147
+ }
128
148
  /** Collect + finalize a searchHints set the way every contributor does (`Array.from(hints).filter(Boolean)`, assigned only when non-empty). */
129
149
  function finalizeHints(out, hints) {
130
150
  if (hints.size > 0)
@@ -245,6 +265,9 @@ export function foldRecognizedMetadata(rendererName, file) {
245
265
  hints.add(`prompt:${target.command.ref}`);
246
266
  else
247
267
  hints.add(`uses:${target.uses.ref}`);
268
+ const used = target.uses.kind !== "builtin-command" ? target.uses.ref : storedCommandRef(target.command);
269
+ if (used)
270
+ out.uses = [used];
248
271
  }
249
272
  else {
250
273
  hints.add(`run:${target.run}`);
@@ -321,6 +344,7 @@ export function foldRecognizedMetadata(rendererName, file) {
321
344
  return out;
322
345
  const plan = result.plan;
323
346
  const hints = new Set();
347
+ const uses = new Set();
324
348
  if (plan.preamble)
325
349
  hints.add(plan.preamble);
326
350
  for (const step of plan.steps) {
@@ -328,8 +352,13 @@ export function foldRecognizedMetadata(rendererName, file) {
328
352
  hints.add(workflowStepInstructions(step));
329
353
  if (step.gate.criteria[0])
330
354
  hints.add(step.gate.criteria[0]);
355
+ const used = workflowStepTarget(step.spec);
356
+ if (used)
357
+ uses.add(used);
331
358
  }
332
359
  out.searchHints = Array.from(hints).filter(Boolean);
360
+ if (uses.size > 0)
361
+ out.uses = [...uses];
333
362
  if (plan.paramSchemas) {
334
363
  const parameters = Object.entries(plan.paramSchemas).map(([name, schema]) => {
335
364
  const description = schema.description;
@@ -395,4 +424,6 @@ export function applyFoldedMetadata(entry, folded) {
395
424
  entry.toc = folded.toc;
396
425
  if (folded.parameters)
397
426
  entry.parameters = folded.parameters;
427
+ if (folded.uses)
428
+ entry.uses = folded.uses;
398
429
  }