akm-cli 0.7.4 → 0.8.0-rc1

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 (158) hide show
  1. package/{CHANGELOG.md → .github/CHANGELOG.md} +34 -1
  2. package/.github/LICENSE +374 -0
  3. package/dist/cli/parse-args.js +43 -0
  4. package/dist/cli.js +1007 -593
  5. package/dist/commands/agent-dispatch.js +102 -0
  6. package/dist/commands/agent-support.js +62 -0
  7. package/dist/commands/config-cli.js +68 -84
  8. package/dist/commands/consolidate.js +823 -0
  9. package/dist/commands/curate.js +1 -0
  10. package/dist/commands/distill-promotion-policy.js +658 -0
  11. package/dist/commands/distill.js +250 -48
  12. package/dist/commands/eval-cases.js +40 -0
  13. package/dist/commands/events.js +12 -24
  14. package/dist/commands/graph.js +222 -0
  15. package/dist/commands/health.js +376 -0
  16. package/dist/commands/help/help-accept.md +9 -0
  17. package/dist/commands/help/help-improve.md +53 -0
  18. package/dist/commands/help/help-proposals.md +15 -0
  19. package/dist/commands/help/help-propose.md +17 -0
  20. package/dist/commands/help/help-reject.md +8 -0
  21. package/dist/commands/history.js +3 -30
  22. package/dist/commands/improve.js +1170 -0
  23. package/dist/commands/info.js +2 -2
  24. package/dist/commands/init.js +2 -2
  25. package/dist/commands/install-audit.js +5 -1
  26. package/dist/commands/installed-stashes.js +118 -138
  27. package/dist/commands/knowledge.js +133 -0
  28. package/dist/commands/lint/agent-linter.js +46 -0
  29. package/dist/commands/lint/base-linter.js +251 -0
  30. package/dist/commands/lint/command-linter.js +46 -0
  31. package/dist/commands/lint/default-linter.js +13 -0
  32. package/dist/commands/lint/index.js +107 -0
  33. package/dist/commands/lint/knowledge-linter.js +13 -0
  34. package/dist/commands/lint/memory-linter.js +58 -0
  35. package/dist/commands/lint/registry.js +33 -0
  36. package/dist/commands/lint/skill-linter.js +42 -0
  37. package/dist/commands/lint/task-linter.js +47 -0
  38. package/dist/commands/lint/types.js +1 -0
  39. package/dist/commands/lint/workflow-linter.js +53 -0
  40. package/dist/commands/lint.js +1 -0
  41. package/dist/commands/migration-help.js +2 -2
  42. package/dist/commands/proposal.js +8 -7
  43. package/dist/commands/propose.js +113 -43
  44. package/dist/commands/reflect.js +175 -41
  45. package/dist/commands/registry-search.js +2 -2
  46. package/dist/commands/remember.js +55 -1
  47. package/dist/commands/schema-repair.js +130 -0
  48. package/dist/commands/search.js +21 -5
  49. package/dist/commands/show.js +131 -52
  50. package/dist/commands/source-add.js +10 -10
  51. package/dist/commands/source-manage.js +11 -19
  52. package/dist/commands/tasks.js +385 -0
  53. package/dist/commands/url-checker.js +39 -0
  54. package/dist/commands/vault.js +7 -33
  55. package/dist/core/action-contributors.js +25 -0
  56. package/dist/core/asset-registry.js +5 -17
  57. package/dist/core/asset-spec.js +11 -1
  58. package/dist/core/common.js +94 -0
  59. package/dist/core/concurrent.js +22 -0
  60. package/dist/core/config.js +229 -122
  61. package/dist/core/events.js +87 -123
  62. package/dist/core/frontmatter.js +3 -1
  63. package/dist/core/markdown.js +17 -0
  64. package/dist/core/memory-improve.js +678 -0
  65. package/dist/core/parse.js +155 -0
  66. package/dist/core/paths.js +101 -3
  67. package/dist/core/proposal-validators.js +61 -0
  68. package/dist/core/proposals.js +49 -38
  69. package/dist/core/state-db.js +775 -0
  70. package/dist/core/time.js +51 -0
  71. package/dist/core/warn.js +59 -1
  72. package/dist/indexer/db-search.js +86 -472
  73. package/dist/indexer/db.js +392 -6
  74. package/dist/indexer/ensure-index.js +133 -0
  75. package/dist/indexer/graph-boost.js +247 -94
  76. package/dist/indexer/graph-db.js +201 -0
  77. package/dist/indexer/graph-dedup.js +99 -0
  78. package/dist/indexer/graph-extraction.js +417 -74
  79. package/dist/indexer/index-context.js +10 -0
  80. package/dist/indexer/indexer.js +466 -298
  81. package/dist/indexer/llm-cache.js +47 -0
  82. package/dist/indexer/match-contributors.js +141 -0
  83. package/dist/indexer/matchers.js +24 -190
  84. package/dist/indexer/memory-inference.js +63 -29
  85. package/dist/indexer/metadata-contributors.js +26 -0
  86. package/dist/indexer/metadata.js +188 -175
  87. package/dist/indexer/path-resolver.js +89 -0
  88. package/dist/indexer/ranking-contributors.js +204 -0
  89. package/dist/indexer/ranking.js +74 -0
  90. package/dist/indexer/search-hit-enrichers.js +22 -0
  91. package/dist/indexer/search-source.js +24 -9
  92. package/dist/indexer/semantic-status.js +2 -16
  93. package/dist/indexer/walker.js +25 -0
  94. package/dist/integrations/agent/config.js +175 -3
  95. package/dist/integrations/agent/index.js +3 -1
  96. package/dist/integrations/agent/pipeline.js +39 -0
  97. package/dist/integrations/agent/profiles.js +67 -5
  98. package/dist/integrations/agent/prompts.js +114 -29
  99. package/dist/integrations/agent/runners.js +31 -0
  100. package/dist/integrations/agent/sdk-runner.js +120 -0
  101. package/dist/integrations/agent/spawn.js +136 -28
  102. package/dist/integrations/lockfile.js +10 -18
  103. package/dist/integrations/session-logs/index.js +65 -0
  104. package/dist/integrations/session-logs/providers/claude-code.js +56 -0
  105. package/dist/integrations/session-logs/providers/opencode.js +52 -0
  106. package/dist/integrations/session-logs/types.js +1 -0
  107. package/dist/llm/call-ai.js +74 -0
  108. package/dist/llm/client.js +63 -86
  109. package/dist/llm/feature-gate.js +27 -16
  110. package/dist/llm/graph-extract.js +297 -64
  111. package/dist/llm/memory-infer.js +52 -71
  112. package/dist/llm/metadata-enhance.js +39 -22
  113. package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
  114. package/dist/output/cli-hints-full.md +277 -0
  115. package/dist/output/cli-hints-short.md +65 -0
  116. package/dist/output/cli-hints.js +2 -309
  117. package/dist/output/renderers.js +196 -124
  118. package/dist/output/shapes.js +41 -3
  119. package/dist/output/text.js +257 -21
  120. package/dist/registry/providers/skills-sh.js +61 -49
  121. package/dist/registry/providers/static-index.js +44 -48
  122. package/dist/setup/setup.js +510 -11
  123. package/dist/sources/provider-factory.js +2 -1
  124. package/dist/sources/providers/git.js +44 -2
  125. package/dist/sources/website-ingest.js +4 -0
  126. package/dist/tasks/backends/cron.js +200 -0
  127. package/dist/tasks/backends/exec-utils.js +25 -0
  128. package/dist/tasks/backends/index.js +32 -0
  129. package/dist/tasks/backends/launchd-template.xml +19 -0
  130. package/dist/tasks/backends/launchd.js +184 -0
  131. package/dist/tasks/backends/schtasks-template.xml +29 -0
  132. package/dist/tasks/backends/schtasks.js +212 -0
  133. package/dist/tasks/parser.js +198 -0
  134. package/dist/tasks/resolveAkmBin.js +84 -0
  135. package/dist/tasks/runner.js +432 -0
  136. package/dist/tasks/schedule.js +208 -0
  137. package/dist/tasks/schema.js +13 -0
  138. package/dist/tasks/validator.js +59 -0
  139. package/dist/wiki/index-template.md +12 -0
  140. package/dist/wiki/ingest-workflow-template.md +54 -0
  141. package/dist/wiki/log-template.md +8 -0
  142. package/dist/wiki/schema-template.md +61 -0
  143. package/dist/wiki/wiki-templates.js +12 -0
  144. package/dist/wiki/wiki.js +10 -61
  145. package/dist/workflows/authoring.js +5 -25
  146. package/dist/workflows/db.js +9 -0
  147. package/dist/workflows/renderer.js +8 -3
  148. package/dist/workflows/runs.js +73 -88
  149. package/dist/workflows/scope-key.js +76 -0
  150. package/dist/workflows/validator.js +1 -1
  151. package/dist/workflows/workflow-template.md +24 -0
  152. package/docs/README.md +3 -0
  153. package/docs/migration/release-notes/0.7.0.md +1 -1
  154. package/docs/migration/release-notes/0.7.4.md +1 -1
  155. package/docs/migration/release-notes/0.7.5.md +20 -0
  156. package/docs/migration/release-notes/0.8.0.md +43 -0
  157. package/package.json +4 -3
  158. package/dist/templates/wiki-templates.js +0 -100
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Schema-repair pass for `akm improve`.
3
+ *
4
+ * Attempts to patch missing frontmatter fields (`description`, `when_to_use`)
5
+ * on assets that failed schema validation, using a single bounded in-tree LLM
6
+ * call per asset. Results are recorded as `schema_repair_invoked` events.
7
+ *
8
+ * This module is extracted from `improve.ts` to make the repair logic
9
+ * independently testable and to use the `tryLlmFeature` seam rather than raw
10
+ * `chatCompletion`.
11
+ */
12
+ import fs from "node:fs";
13
+ import { stringify as yamlStringify } from "yaml";
14
+ import { parseAssetRef } from "../core/asset-ref";
15
+ import { appendEvent, readEvents } from "../core/events";
16
+ import { parseFrontmatter } from "../core/frontmatter";
17
+ import { info } from "../core/warn";
18
+ import { resolveAssetPath } from "../indexer/path-resolver";
19
+ import { chatCompletion, parseEmbeddedJsonResponse } from "../llm/client";
20
+ // ── Constants ────────────────────────────────────────────────────────────────
21
+ /** Minimum gap between schema-repair attempts on the same asset. */
22
+ const SCHEMA_REPAIR_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
23
+ // ── Main ─────────────────────────────────────────────────────────────────────
24
+ /**
25
+ * Run the schema-repair loop for a batch of validation failures.
26
+ * Returns a list of per-asset outcome records and the set of refs that were
27
+ * successfully repaired (so the caller can exclude them from skip logic).
28
+ */
29
+ export async function runSchemaRepairPass(failures, options) {
30
+ const repairs = [];
31
+ const repairedRefs = new Set();
32
+ const { startMs, budgetMs, llmConfig, stashDir, findFilePath = defaultFindFilePath, isLessonCandidateFn = defaultIsLessonCandidate, } = options;
33
+ for (const failure of failures) {
34
+ if (Date.now() - startMs >= budgetMs)
35
+ break;
36
+ // Cooldown: skip repair if we ran it successfully recently.
37
+ const recentRepairs = readEvents({ type: "schema_repair_invoked", ref: failure.ref });
38
+ const lastRepair = recentRepairs.events
39
+ .filter((e) => e.metadata?.outcome === "written")
40
+ .sort((a, b) => new Date(b.ts ?? 0).getTime() - new Date(a.ts ?? 0).getTime())[0];
41
+ if (lastRepair?.ts && Date.now() - new Date(lastRepair.ts).getTime() < SCHEMA_REPAIR_COOLDOWN_MS) {
42
+ repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "skipped" });
43
+ continue;
44
+ }
45
+ const filePath = await findFilePath(failure.ref, stashDir);
46
+ if (!filePath) {
47
+ repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "skipped" });
48
+ continue;
49
+ }
50
+ try {
51
+ const raw = fs.readFileSync(filePath, "utf8");
52
+ const fm = parseFrontmatter(raw);
53
+ const missingFields = [];
54
+ if (!fm.data.description)
55
+ missingFields.push("description");
56
+ if (isLessonCandidateFn(failure.ref) && !fm.data.when_to_use)
57
+ missingFields.push("when_to_use");
58
+ if (missingFields.length === 0) {
59
+ repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "skipped" });
60
+ continue;
61
+ }
62
+ const fieldList = missingFields.join(" and ");
63
+ info(`[improve] schema-repair ${failure.ref} (${fieldList})`);
64
+ const bodyPreview = (fm.content ?? raw).slice(0, 2000);
65
+ const llmResponse = await chatCompletion(llmConfig, [
66
+ {
67
+ role: "system",
68
+ content: `You generate concise asset frontmatter fields. Respond with a JSON object containing only the missing fields. No prose, no markdown fences.`,
69
+ },
70
+ {
71
+ role: "user",
72
+ content: `Generate the missing frontmatter fields (${fieldList}) for this ${parseAssetRef(failure.ref).type} asset. Return ONLY valid JSON like {"description": "...", "when_to_use": "..."}\n\n${bodyPreview}`,
73
+ },
74
+ ]);
75
+ const parsed = parseEmbeddedJsonResponse(llmResponse.trim());
76
+ if (!parsed) {
77
+ repairs.push({
78
+ ref: failure.ref,
79
+ reason: failure.reason,
80
+ outcome: "error",
81
+ error: "LLM returned unparseable JSON for schema repair",
82
+ });
83
+ continue;
84
+ }
85
+ const newFm = { ...fm.data };
86
+ if (parsed.description)
87
+ newFm.description = parsed.description;
88
+ if (parsed.when_to_use)
89
+ newFm.when_to_use = parsed.when_to_use;
90
+ const fmStr = yamlStringify(newFm).trimEnd();
91
+ const newContent = `---\n${fmStr}\n---\n${fm.content}`;
92
+ fs.writeFileSync(filePath, newContent, "utf8");
93
+ info(`[improve] schema-repair written: ${failure.ref}`);
94
+ appendEvent({
95
+ eventType: "schema_repair_invoked",
96
+ ref: failure.ref,
97
+ metadata: { outcome: "written", reason: failure.reason },
98
+ });
99
+ repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "written" });
100
+ repairedRefs.add(failure.ref);
101
+ }
102
+ catch (e) {
103
+ appendEvent({
104
+ eventType: "schema_repair_invoked",
105
+ ref: failure.ref,
106
+ metadata: { outcome: "error", reason: failure.reason, error: String(e) },
107
+ });
108
+ repairs.push({ ref: failure.ref, reason: failure.reason, outcome: "error", error: String(e) });
109
+ }
110
+ }
111
+ return { repairs, repairedRefs };
112
+ }
113
+ // ── Default seam implementations ─────────────────────────────────────────────
114
+ function defaultIsLessonCandidate(ref) {
115
+ try {
116
+ const parsed = parseAssetRef(ref);
117
+ return parsed.type === "lesson";
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ }
123
+ async function defaultFindFilePath(ref, stashDir) {
124
+ return resolveAssetPath(ref, {
125
+ stashDir,
126
+ mode: "index-first",
127
+ directoryIndexNames: ["SKILL.md", "index.md", "README.md"],
128
+ preserveDirectNameFallback: true,
129
+ });
130
+ }
@@ -11,7 +11,7 @@
11
11
  import { loadConfig } from "../core/config";
12
12
  import { UsageError } from "../core/errors";
13
13
  import { appendEvent } from "../core/events";
14
- import { closeDatabase, openExistingDatabase } from "../indexer/db";
14
+ import { bumpUtilityScoresBatch, closeDatabase, openExistingDatabase } from "../indexer/db";
15
15
  import { searchLocal } from "../indexer/db-search";
16
16
  import { resolveSourceEntries } from "../indexer/search-source";
17
17
  // Eagerly import source providers to trigger self-registration before the
@@ -48,6 +48,7 @@ export async function akmSearch(input) {
48
48
  const stashDir = sources[0].path;
49
49
  const filters = normalizeScopeFilters(input.filters);
50
50
  const includeProposed = input.includeProposed === true;
51
+ const belief = input.belief ?? "all";
51
52
  const localResult = source === "registry"
52
53
  ? undefined
53
54
  : await searchLocal({
@@ -59,6 +60,7 @@ export async function akmSearch(input) {
59
60
  config,
60
61
  filters,
61
62
  includeProposed,
63
+ beliefFilter: belief,
62
64
  });
63
65
  const registryResult = source === "stash" ? undefined : await searchRegistry(query, { limit, registries: config.registries });
64
66
  if (source === "stash") {
@@ -73,7 +75,7 @@ export async function akmSearch(input) {
73
75
  warnings: localResult?.warnings?.length ? localResult.warnings : undefined,
74
76
  timing: { totalMs: Date.now() - t0, rankMs: localResult?.rankMs, embedMs: localResult?.embedMs },
75
77
  };
76
- logSearchEvent(query, response);
78
+ logSearchEvent(query, response, undefined, localResult?.mode ?? "keyword");
77
79
  return response;
78
80
  }
79
81
  const registryHits = (registryResult?.hits ?? []).map((hit) => {
@@ -124,7 +126,7 @@ export async function akmSearch(input) {
124
126
  warnings: warnings.length ? warnings : undefined,
125
127
  timing: { totalMs: Date.now() - t0 },
126
128
  };
127
- logSearchEvent(query, response);
129
+ logSearchEvent(query, response, undefined, localResult?.mode ?? "keyword");
128
130
  return response;
129
131
  }
130
132
  /**
@@ -160,13 +162,13 @@ function resolveEntryIds(db, hits) {
160
162
  * Per-entry events are recorded only for stash hits because registry hits
161
163
  * have no local entry_id to reference.
162
164
  */
163
- function logSearchEvent(query, response, existingDb) {
165
+ function logSearchEvent(query, response, existingDb, mode = "keyword") {
164
166
  // Emit a structured event to events.jsonl so workflow-trace consumers
165
167
  // detect akm search invocations without relying on stdout scraping.
166
168
  const stashHits = response.hits.filter((h) => h.type !== "registry");
167
169
  appendEvent({
168
170
  eventType: "search",
169
- metadata: { query, hitCount: stashHits.length, resultRefs: stashHits.map((h) => h.ref) },
171
+ metadata: { query, hitCount: stashHits.length, resultRefs: stashHits.map((h) => h.ref), mode },
170
172
  });
171
173
  try {
172
174
  const db = existingDb ?? openExistingDatabase();
@@ -180,6 +182,12 @@ function logSearchEvent(query, response, existingDb) {
180
182
  entry_ref: ref,
181
183
  });
182
184
  }
185
+ // Bump utility scores for all resolved entries (MemRL retrieval signal).
186
+ // The indexer overwrites these at next reindex; bumps are temporary hints.
187
+ const resolvedIds = resolved.map((r) => r.entryId).filter((id) => id !== undefined);
188
+ if (resolvedIds.length > 0) {
189
+ bumpUtilityScoresBatch(db, resolvedIds, 1.0);
190
+ }
183
191
  // Count registry hits separately so registry-only searches record a
184
192
  // non-zero resultCount. response.hits is always [] when source="registry".
185
193
  const stashHitCount = response.hits.length;
@@ -192,6 +200,7 @@ function logSearchEvent(query, response, existingDb) {
192
200
  stashHitCount,
193
201
  registryHitCount,
194
202
  resolvedCount: resolved.length,
203
+ mode,
195
204
  }),
196
205
  });
197
206
  }
@@ -221,6 +230,13 @@ export function parseSearchSource(source) {
221
230
  return "stash";
222
231
  throw new UsageError(`Invalid value for --source: ${String(source)}. Expected one of: stash|registry|both`, "INVALID_SOURCE_VALUE");
223
232
  }
233
+ export function parseBeliefFilterMode(value) {
234
+ if (value === undefined || value === "all")
235
+ return "all";
236
+ if (value === "current" || value === "historical")
237
+ return value;
238
+ throw new UsageError(`Invalid value for --belief: ${String(value)}. Expected one of: all|current|historical`, "INVALID_FLAG_VALUE");
239
+ }
224
240
  /**
225
241
  * Strip empty / non-string values from a scope filter object. Returns
226
242
  * `undefined` when nothing meaningful remains, so callers don't pay for an
@@ -9,15 +9,9 @@
9
9
  * edit-hints, summary-detail truncation) lives below in this file. The flow:
10
10
  *
11
11
  * 1. Special-case wiki-root refs (`wiki:<name>` with no page path).
12
- * 2. Ask `indexer.lookup(ref)` for the row in the FTS index.
13
- * 3. Fall back to the on-disk type-dir resolver only when the index has
14
- * no matching row — covers the "indexed yet?" gap when the user has
15
- * just added a file and not run `akm index`.
12
+ * 2. Auto-index when stale so the index is current.
13
+ * 3. Ask `indexer.lookup(ref)` for the row in the FTS index.
16
14
  * 4. Render the file via the matcher/renderer pipeline.
17
- *
18
- * Step (2) is the v1 spec change: reading is the indexer's job. Step (3) is a
19
- * pragmatic safety net (NOT remote provider fallback, which the spec
20
- * forbids — "Show: Local FTS5 index only. No remote provider fallback.").
21
15
  */
22
16
  import fs from "node:fs";
23
17
  import path from "node:path";
@@ -27,15 +21,18 @@ import { NotFoundError, UsageError } from "../core/errors";
27
21
  import { appendEvent, readEvents } from "../core/events";
28
22
  import { parseFrontmatter, toStringOrUndefined } from "../core/frontmatter";
29
23
  import { closeDatabase, findEntryIdByRef, openExistingDatabase } from "../indexer/db";
24
+ import { ensureIndex } from "../indexer/ensure-index";
30
25
  import { buildFileContext, buildRenderContext, getRenderer, runMatchers } from "../indexer/file-context";
26
+ import { listRelatedPathsForFile } from "../indexer/graph-boost";
31
27
  import { lookup } from "../indexer/indexer";
28
+ import { resolveAssetPath } from "../indexer/path-resolver";
32
29
  import { buildEditHint, findSourceForPath, isEditable, resolveSourceEntries } from "../indexer/search-source";
33
30
  import { insertUsageEvent } from "../indexer/usage-events";
34
31
  import { resolveSourcesForOrigin } from "../registry/origin-resolve";
35
32
  // Eagerly import source providers to trigger self-registration.
36
33
  import "../sources/providers/index";
37
- import { resolveAssetPath } from "../sources/resolve";
38
34
  import { getActiveWorkflowRun } from "../workflows/runs";
35
+ import { getCurrentWorkflowScopeKey } from "../workflows/scope-key";
39
36
  /**
40
37
  * Show a wiki root (no page path) — returns the same payload as
41
38
  * `akm wiki show <name>`.
@@ -128,7 +125,12 @@ export async function akmShowUnified(input) {
128
125
  new NotFoundError(`Wiki not found: ${parsed.name}. Run \`akm wiki create ${parsed.name}\` to create it.`));
129
126
  }
130
127
  }
131
- // Try local filesystem (FTS5 index lookup, then on-disk fallback)
128
+ // Auto-index when stale so the index is current before lookup.
129
+ const allSources = resolveSourceEntries();
130
+ if (allSources.length > 0) {
131
+ await ensureIndex(allSources[0].path);
132
+ }
133
+ // Try local filesystem (FTS5 index lookup)
132
134
  const result = await showLocal(input);
133
135
  // Scope filter narrows resolution: if --scope was supplied, the asset's
134
136
  // frontmatter scope must satisfy every supplied key. We re-read the file
@@ -201,6 +203,31 @@ function logShowEvent(ref, existingDb) {
201
203
  // detect akm show invocations without relying on stdout scraping.
202
204
  const parsed = parseAssetRef(ref);
203
205
  appendEvent({ eventType: "show", ref, metadata: { type: parsed.type, name: parsed.name } });
206
+ // Detect if this show is a selection from a recent search result.
207
+ try {
208
+ const { events: recentSearches } = readEvents({ type: "search" });
209
+ const cutoffMs = Date.now() - 60_000;
210
+ const matchingSearch = [...recentSearches].reverse().find((e) => {
211
+ if (!e.ts || new Date(e.ts).getTime() < cutoffMs)
212
+ return false;
213
+ const refs = e.metadata?.resultRefs ?? [];
214
+ return refs.includes(ref);
215
+ });
216
+ if (matchingSearch) {
217
+ appendEvent({
218
+ eventType: "select",
219
+ ref,
220
+ metadata: {
221
+ query: matchingSearch.metadata?.query,
222
+ searchTs: matchingSearch.ts,
223
+ rankPosition: (matchingSearch.metadata?.resultRefs ?? []).indexOf(ref),
224
+ },
225
+ });
226
+ }
227
+ }
228
+ catch {
229
+ /* fire-and-forget — select is best-effort */
230
+ }
204
231
  try {
205
232
  const db = existingDb ?? openExistingDatabase();
206
233
  try {
@@ -219,40 +246,6 @@ function logShowEvent(ref, existingDb) {
219
246
  /* fire-and-forget */
220
247
  }
221
248
  }
222
- /**
223
- * Resolve an asset path to a file via:
224
- * 1. `indexer.lookup(ref)` — the spec's primary path (§6.2).
225
- * 2. On-disk type-dir traversal — fallback for files not yet indexed.
226
- *
227
- * Returns `undefined` if neither path finds a match.
228
- */
229
- async function resolvePathViaIndexThenDisk(parsed, searchSourceDirs) {
230
- // Step 1: indexer
231
- try {
232
- const entry = await lookup(parsed);
233
- if (entry) {
234
- return { assetPath: entry.filePath };
235
- }
236
- }
237
- catch (err) {
238
- // Index unavailable (e.g. DB doesn't exist yet) — fall back to disk walk.
239
- if (!(err instanceof NotFoundError)) {
240
- // continue to disk fallback
241
- }
242
- }
243
- // Step 2: on-disk type-dir traversal
244
- let lastError;
245
- for (const dir of searchSourceDirs) {
246
- try {
247
- const assetPath = await resolveAssetPath(dir, parsed.type, parsed.name);
248
- return { assetPath, lastError };
249
- }
250
- catch (err) {
251
- lastError = err instanceof Error ? err : new Error(String(err));
252
- }
253
- }
254
- return lastError ? { assetPath: "", lastError } : undefined;
255
- }
256
249
  /** @internal Use akmShowUnified() for all external callers. */
257
250
  export async function showLocal(input) {
258
251
  const parsed = parseAssetRef(input.ref);
@@ -273,13 +266,11 @@ export async function showLocal(input) {
273
266
  }
274
267
  }
275
268
  if (!assetPath) {
276
- const resolved = await resolvePathViaIndexThenDisk(parsed, allSourceDirs);
277
- if (resolved?.assetPath) {
278
- assetPath = resolved.assetPath;
279
- }
280
- else if (resolved?.lastError) {
281
- lastError = resolved.lastError;
282
- }
269
+ const resolvedAssetPath = await resolveAssetPath(parsed, {
270
+ stashDir: input.stashDir,
271
+ mode: "index-first",
272
+ });
273
+ assetPath = resolvedAssetPath ?? undefined;
283
274
  }
284
275
  if (!assetPath && parsed.origin && searchSources.length === 0) {
285
276
  const installCmd = `akm add ${parsed.origin}`;
@@ -318,8 +309,23 @@ export async function showLocal(input) {
318
309
  origin: source?.registryId ?? null,
319
310
  editable,
320
311
  ...(!editable ? { editHint: buildEditHint(assetPath, parsed.type, parsed.name, source?.registryId) } : {}),
312
+ related: (() => {
313
+ let db;
314
+ try {
315
+ db = openExistingDatabase();
316
+ const related = listRelatedPathsForFile(sourceStashDir, assetPath, 5, db);
317
+ return { total: related.length, hits: related };
318
+ }
319
+ catch {
320
+ return { total: 0, hits: [] };
321
+ }
322
+ finally {
323
+ if (db)
324
+ closeDatabase(db);
325
+ }
326
+ })(),
321
327
  };
322
- const activeRun = getActiveWorkflowRun();
328
+ const activeRun = await getActiveWorkflowRun(getCurrentWorkflowScopeKey());
323
329
  if (activeRun) {
324
330
  fullResponse.activeRun = activeRun;
325
331
  }
@@ -396,3 +402,76 @@ function buildSummaryResponse(full, assetPath) {
396
402
  };
397
403
  return summary;
398
404
  }
405
+ // ── argv normalisation ───────────────────────────────────────────────────────
406
+ const SHOW_VIEW_MODES = new Set(["toc", "frontmatter", "full", "section", "lines"]);
407
+ /**
408
+ * Normalize argv so positional view-mode arguments after the asset ref
409
+ * are rewritten into internal flags that citty can parse.
410
+ *
411
+ * Converts:
412
+ * akm show knowledge:guide.md toc → akm show knowledge:guide.md --akmView toc
413
+ * akm show knowledge:guide.md section Auth → akm show knowledge:guide.md --akmView section --akmHeading Auth
414
+ * akm show knowledge:guide.md lines 1 50 → akm show knowledge:guide.md --akmView lines --akmStart 1 --akmEnd 50
415
+ *
416
+ * Legacy `--view` is intentionally unsupported.
417
+ * Returns a new array; the input is never modified.
418
+ */
419
+ export function normalizeShowArgv(argv) {
420
+ // argv[0]=bun argv[1]=script argv[2]=subcommand argv[3]=ref argv[4..]=rest
421
+ if (argv[2] !== "show")
422
+ return argv;
423
+ if (argv[3] === "proposal")
424
+ return argv;
425
+ if (argv.includes("--view") || argv.includes("--heading") || argv.includes("--start") || argv.includes("--end")) {
426
+ throw new UsageError('Legacy show flags are no longer supported. Use positional syntax like `akm show knowledge:guide toc` or `akm show knowledge:guide section "Auth"`.');
427
+ }
428
+ // Separate global flags from positional/show-specific args
429
+ const prefix = argv.slice(0, 3); // [bun, script, show]
430
+ const rest = argv.slice(3);
431
+ const globalFlags = [];
432
+ const showArgs = [];
433
+ for (let i = 0; i < rest.length; i++) {
434
+ const arg = rest[i];
435
+ if (arg === "--quiet" || arg === "-q" || arg === "--for-agent" || arg === "--for-agent=true") {
436
+ globalFlags.push(arg);
437
+ continue;
438
+ }
439
+ if (arg.startsWith("--format=") || arg.startsWith("--detail=")) {
440
+ globalFlags.push(arg);
441
+ continue;
442
+ }
443
+ if (arg === "--format" || arg === "--detail") {
444
+ globalFlags.push(arg);
445
+ if (rest[i + 1] !== undefined) {
446
+ globalFlags.push(rest[i + 1]);
447
+ i++;
448
+ }
449
+ continue;
450
+ }
451
+ showArgs.push(arg);
452
+ }
453
+ // showArgs[0] = ref, showArgs[1] = potential view mode, showArgs[2..] = view params
454
+ const ref = showArgs[0];
455
+ const viewMode = showArgs[1];
456
+ if (!ref || !viewMode || !SHOW_VIEW_MODES.has(viewMode)) {
457
+ return argv;
458
+ }
459
+ const result = [...prefix, ref, "--akmView", viewMode];
460
+ if (viewMode === "section") {
461
+ // Next arg is the heading name; pass empty string when missing so the
462
+ // show handler can produce a clear "section not found" error.
463
+ const heading = showArgs[2] ?? "";
464
+ result.push("--akmHeading", heading);
465
+ }
466
+ else if (viewMode === "lines") {
467
+ // Next two args are start and end
468
+ const start = showArgs[2];
469
+ const end = showArgs[3];
470
+ if (start)
471
+ result.push("--akmStart", start);
472
+ if (end)
473
+ result.push("--akmEnd", end);
474
+ }
475
+ result.push(...globalFlags);
476
+ return result;
477
+ }
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { isHttpUrl, resolveStashDir } from "../core/common";
4
- import { loadConfig, loadUserConfig, saveConfig } from "../core/config";
4
+ import { getSources, loadConfig, loadUserConfig, saveConfig } from "../core/config";
5
5
  import { ConfigError, UsageError } from "../core/errors";
6
6
  import { warn } from "../core/warn";
7
7
  import { akmIndex } from "../indexer/indexer";
@@ -74,7 +74,7 @@ async function addLocalSource(ref, sourcePath, stashDir, wikiName, explicitName)
74
74
  // Derive the canonical name: explicit --name wins, then wiki name, then readable path.
75
75
  const derivedName = explicitName ?? wikiName ?? toReadableId(resolvedPath);
76
76
  // Check for duplicates in sources[]
77
- const sources = [...(config.sources ?? config.stashes ?? [])];
77
+ const sources = [...getSources(config)];
78
78
  const existing = sources.find((s) => s.type === "filesystem" && s.path && path.resolve(s.path) === resolvedPath);
79
79
  let persistedEntry;
80
80
  if (!existing) {
@@ -85,7 +85,7 @@ async function addLocalSource(ref, sourcePath, stashDir, wikiName, explicitName)
85
85
  ...(wikiName ? { wikiName } : {}),
86
86
  };
87
87
  sources.push(persistedEntry);
88
- saveConfig({ ...config, sources, stashes: undefined });
88
+ saveConfig({ ...config, sources });
89
89
  }
90
90
  else {
91
91
  let changed = false;
@@ -99,7 +99,7 @@ async function addLocalSource(ref, sourcePath, stashDir, wikiName, explicitName)
99
99
  changed = true;
100
100
  }
101
101
  if (changed)
102
- saveConfig({ ...config, sources, stashes: undefined });
102
+ saveConfig({ ...config, sources });
103
103
  persistedEntry = existing;
104
104
  }
105
105
  const index = await akmIndex({ stashDir });
@@ -116,7 +116,7 @@ async function addLocalSource(ref, sourcePath, stashDir, wikiName, explicitName)
116
116
  ...(persistedEntry.wikiName ? { wiki: persistedEntry.wikiName } : {}),
117
117
  },
118
118
  config: {
119
- sourceCount: (updatedConfig.sources ?? updatedConfig.stashes ?? []).length,
119
+ sourceCount: getSources(updatedConfig).length,
120
120
  installedKitCount: updatedConfig.installed?.length ?? 0,
121
121
  },
122
122
  index: {
@@ -131,7 +131,7 @@ async function addLocalSource(ref, sourcePath, stashDir, wikiName, explicitName)
131
131
  async function addWebsiteSource(ref, stashDir, name, options, wikiName) {
132
132
  const normalizedUrl = validateWebsiteInputUrl(ref);
133
133
  const config = loadUserConfig();
134
- const sources = [...(config.sources ?? config.stashes ?? [])];
134
+ const sources = [...getSources(config)];
135
135
  let entry = sources.find((stash) => stash.type === "website" && stash.url === normalizedUrl);
136
136
  if (!entry) {
137
137
  entry = {
@@ -142,7 +142,7 @@ async function addWebsiteSource(ref, stashDir, name, options, wikiName) {
142
142
  ...(wikiName ? { wikiName } : {}),
143
143
  };
144
144
  sources.push(entry);
145
- saveConfig({ ...config, sources, stashes: undefined });
145
+ saveConfig({ ...config, sources });
146
146
  }
147
147
  else {
148
148
  let changed = false;
@@ -155,7 +155,7 @@ async function addWebsiteSource(ref, stashDir, name, options, wikiName) {
155
155
  changed = true;
156
156
  }
157
157
  if (changed)
158
- saveConfig({ ...config, sources, stashes: undefined });
158
+ saveConfig({ ...config, sources });
159
159
  }
160
160
  const cachePaths = await ensureWebsiteMirror(entry, { requireStashDir: true });
161
161
  const index = await akmIndex({ stashDir });
@@ -172,7 +172,7 @@ async function addWebsiteSource(ref, stashDir, name, options, wikiName) {
172
172
  ...(entry.wikiName ? { wiki: entry.wikiName } : {}),
173
173
  },
174
174
  config: {
175
- sourceCount: (updatedConfig.sources ?? updatedConfig.stashes ?? []).length,
175
+ sourceCount: getSources(updatedConfig).length,
176
176
  installedKitCount: updatedConfig.installed?.length ?? 0,
177
177
  },
178
178
  index: {
@@ -268,7 +268,7 @@ async function addRegistryStash(ref, stashDir, trustThisInstall, writable, wikiN
268
268
  audit,
269
269
  },
270
270
  config: {
271
- sourceCount: (updatedConfig.sources ?? updatedConfig.stashes ?? []).length,
271
+ sourceCount: getSources(updatedConfig).length,
272
272
  installedKitCount: updatedConfig.installed?.length ?? 0,
273
273
  },
274
274
  index: {
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
- import { loadConfig, loadUserConfig, saveConfig } from "../core/config";
2
+ import { isRemoteUrl } from "../core/common";
3
+ import { getSources, loadConfig, loadUserConfig, saveConfig } from "../core/config";
3
4
  import { ConfigError, UsageError } from "../core/errors";
4
5
  import { resolveSourceEntries } from "../indexer/search-source";
5
6
  // ── Operations ──────────────────────────────────────────────────────────────
@@ -19,14 +20,9 @@ export function addStash(opts) {
19
20
  throw new ConfigError("writable: true is only supported on filesystem and git sources", "INVALID_CONFIG_FILE");
20
21
  }
21
22
  const config = loadUserConfig();
22
- const sources = [...(config.sources ?? config.stashes ?? [])];
23
- const isRemoteUrl = target.startsWith("http://") ||
24
- target.startsWith("https://") ||
25
- target.startsWith("git@") ||
26
- target.startsWith("ssh://") ||
27
- target.startsWith("git://");
23
+ const sources = [...getSources(config)];
28
24
  let entry;
29
- if (isRemoteUrl) {
25
+ if (isRemoteUrl(target)) {
30
26
  if (!providerType) {
31
27
  throw new UsageError("--provider is required for URL sources (e.g. --provider git --provider website)");
32
28
  }
@@ -53,7 +49,7 @@ export function addStash(opts) {
53
49
  entry.name = name;
54
50
  }
55
51
  sources.push(entry);
56
- saveConfig({ ...config, sources, stashes: undefined });
52
+ saveConfig({ ...config, sources });
57
53
  return { sources, added: true, entry };
58
54
  }
59
55
  /**
@@ -62,16 +58,12 @@ export function addStash(opts) {
62
58
  */
63
59
  export function removeStash(target) {
64
60
  const config = loadUserConfig();
65
- const sources = [...(config.sources ?? config.stashes ?? [])];
66
- const isUrl = target.startsWith("http://") ||
67
- target.startsWith("https://") ||
68
- target.startsWith("git@") ||
69
- target.startsWith("ssh://") ||
70
- target.startsWith("git://");
71
- const resolvedPath = !isUrl ? path.resolve(target) : undefined;
61
+ const sources = [...getSources(config)];
62
+ const isUrlTarget = isRemoteUrl(target);
63
+ const resolvedPath = !isUrlTarget ? path.resolve(target) : undefined;
72
64
  // Try URL match first, then path, then name (most specific → least specific)
73
65
  let idx = -1;
74
- if (isUrl) {
66
+ if (isUrlTarget) {
75
67
  idx = sources.findIndex((s) => s.url === target);
76
68
  }
77
69
  if (idx === -1 && resolvedPath) {
@@ -84,7 +76,7 @@ export function removeStash(target) {
84
76
  return { sources, removed: false, message: "No matching source found" };
85
77
  }
86
78
  const removed = sources.splice(idx, 1)[0];
87
- saveConfig({ ...config, sources, stashes: undefined });
79
+ saveConfig({ ...config, sources });
88
80
  return { sources, removed: true, entry: removed };
89
81
  }
90
82
  /**
@@ -93,6 +85,6 @@ export function removeStash(target) {
93
85
  export function listStashes() {
94
86
  const config = loadConfig();
95
87
  const localSources = resolveSourceEntries();
96
- const sources = config.sources ?? config.stashes ?? [];
88
+ const sources = getSources(config);
97
89
  return { localSources, sources };
98
90
  }