akm-cli 0.5.0 → 0.6.0-rc2
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 +53 -5
- package/README.md +9 -9
- package/dist/cli.js +379 -1448
- package/dist/{completions.js → commands/completions.js} +1 -1
- package/dist/{config-cli.js → commands/config-cli.js} +109 -11
- package/dist/commands/curate.js +263 -0
- package/dist/{info.js → commands/info.js} +17 -11
- package/dist/{init.js → commands/init.js} +4 -4
- package/dist/{install-audit.js → commands/install-audit.js} +14 -2
- package/dist/{installed-kits.js → commands/installed-stashes.js} +122 -50
- package/dist/commands/migration-help.js +141 -0
- package/dist/{registry-search.js → commands/registry-search.js} +68 -9
- package/dist/commands/remember.js +178 -0
- package/dist/{stash-search.js → commands/search.js} +28 -69
- package/dist/{self-update.js → commands/self-update.js} +3 -3
- package/dist/{stash-show.js → commands/show.js} +106 -81
- package/dist/{stash-add.js → commands/source-add.js} +133 -67
- package/dist/{stash-clone.js → commands/source-clone.js} +15 -13
- package/dist/{stash-source-manage.js → commands/source-manage.js} +24 -24
- package/dist/{vault.js → commands/vault.js} +43 -0
- package/dist/{stash-ref.js → core/asset-ref.js} +4 -4
- package/dist/{asset-registry.js → core/asset-registry.js} +30 -6
- package/dist/{asset-spec.js → core/asset-spec.js} +13 -6
- package/dist/{common.js → core/common.js} +147 -50
- package/dist/{config.js → core/config.js} +288 -29
- package/dist/core/errors.js +90 -0
- package/dist/{frontmatter.js → core/frontmatter.js} +64 -8
- package/dist/{paths.js → core/paths.js} +4 -4
- package/dist/core/write-source.js +280 -0
- package/dist/{local-search.js → indexer/db-search.js} +49 -32
- package/dist/{db.js → indexer/db.js} +210 -81
- package/dist/{file-context.js → indexer/file-context.js} +3 -3
- package/dist/{indexer.js → indexer/indexer.js} +153 -30
- package/dist/{manifest.js → indexer/manifest.js} +10 -10
- package/dist/{matchers.js → indexer/matchers.js} +4 -7
- package/dist/{metadata.js → indexer/metadata.js} +9 -5
- package/dist/{search-source.js → indexer/search-source.js} +97 -55
- package/dist/{semantic-status.js → indexer/semantic-status.js} +2 -2
- package/dist/{walker.js → indexer/walker.js} +1 -1
- package/dist/{lockfile.js → integrations/lockfile.js} +29 -2
- package/dist/{llm.js → llm/client.js} +12 -48
- package/dist/llm/embedder.js +127 -0
- package/dist/llm/embedders/cache.js +47 -0
- package/dist/llm/embedders/local.js +152 -0
- package/dist/llm/embedders/remote.js +121 -0
- package/dist/llm/embedders/types.js +39 -0
- package/dist/llm/metadata-enhance.js +53 -0
- package/dist/output/cli-hints.js +301 -0
- package/dist/output/context.js +95 -0
- package/dist/{renderers.js → output/renderers.js} +57 -61
- package/dist/output/shapes.js +212 -0
- package/dist/output/text.js +520 -0
- package/dist/{registry-build-index.js → registry/build-index.js} +48 -32
- package/dist/{create-provider-registry.js → registry/create-provider-registry.js} +6 -2
- package/dist/registry/factory.js +33 -0
- package/dist/{origin-resolve.js → registry/origin-resolve.js} +1 -1
- package/dist/registry/providers/index.js +11 -0
- package/dist/{providers → registry/providers}/skills-sh.js +60 -4
- package/dist/{providers → registry/providers}/static-index.js +126 -56
- package/dist/registry/providers/types.js +25 -0
- package/dist/{registry-resolve.js → registry/resolve.js} +10 -6
- package/dist/{detect.js → setup/detect.js} +0 -27
- package/dist/{ripgrep-install.js → setup/ripgrep-install.js} +1 -1
- package/dist/{ripgrep-resolve.js → setup/ripgrep-resolve.js} +2 -2
- package/dist/{setup.js → setup/setup.js} +162 -129
- package/dist/setup/steps.js +45 -0
- package/dist/{kit-include.js → sources/include.js} +1 -1
- package/dist/sources/provider-factory.js +36 -0
- package/dist/sources/provider.js +21 -0
- package/dist/sources/providers/filesystem.js +35 -0
- package/dist/{stash-providers → sources/providers}/git.js +218 -28
- package/dist/{stash-providers → sources/providers}/index.js +4 -4
- package/dist/sources/providers/install-types.js +14 -0
- package/dist/sources/providers/npm.js +160 -0
- package/dist/sources/providers/provider-utils.js +173 -0
- package/dist/sources/providers/sync-from-ref.js +45 -0
- package/dist/sources/providers/tar-utils.js +154 -0
- package/dist/{stash-providers → sources/providers}/website.js +60 -20
- package/dist/{stash-resolve.js → sources/resolve.js} +13 -12
- package/dist/{wiki.js → wiki/wiki.js} +18 -17
- package/dist/{workflow-authoring.js → workflows/authoring.js} +48 -17
- package/dist/{workflow-cli.js → workflows/cli.js} +2 -1
- package/dist/{workflow-db.js → workflows/db.js} +1 -1
- package/dist/workflows/document-cache.js +20 -0
- package/dist/workflows/parser.js +379 -0
- package/dist/workflows/renderer.js +78 -0
- package/dist/{workflow-runs.js → workflows/runs.js} +84 -30
- package/dist/workflows/schema.js +11 -0
- package/dist/workflows/validator.js +48 -0
- package/docs/README.md +30 -0
- package/docs/migration/release-notes/0.0.13.md +4 -0
- package/docs/migration/release-notes/0.1.0.md +6 -0
- package/docs/migration/release-notes/0.2.0.md +6 -0
- package/docs/migration/release-notes/0.3.0.md +5 -0
- package/docs/migration/release-notes/0.5.0.md +6 -0
- package/docs/migration/release-notes/0.6.0.md +75 -0
- package/docs/migration/release-notes/README.md +21 -0
- package/package.json +3 -2
- package/dist/embedder.js +0 -351
- package/dist/errors.js +0 -34
- package/dist/migration-help.js +0 -110
- package/dist/registry-factory.js +0 -19
- package/dist/registry-install.js +0 -532
- package/dist/ripgrep.js +0 -2
- package/dist/stash-provider-factory.js +0 -35
- package/dist/stash-provider.js +0 -1
- package/dist/stash-providers/filesystem.js +0 -41
- package/dist/stash-providers/openviking.js +0 -348
- package/dist/stash-providers/provider-utils.js +0 -11
- package/dist/stash-types.js +0 -1
- package/dist/workflow-markdown.js +0 -251
- /package/dist/{markdown.js → core/markdown.js} +0 -0
- /package/dist/{warn.js → core/warn.js} +0 -0
- /package/dist/{search-fields.js → indexer/search-fields.js} +0 -0
- /package/dist/{usage-events.js → indexer/usage-events.js} +0 -0
- /package/dist/{github.js → integrations/github.js} +0 -0
- /package/dist/{registry-provider.js → registry/types.js} +0 -0
- /package/dist/{registry-types.js → sources/types.js} +0 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory-specific helpers for `akm remember`.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `src/cli.ts` so the domain logic (frontmatter assembly,
|
|
5
|
+
* heuristic derivation, LLM enrichment) is testable in isolation and the
|
|
6
|
+
* CLI entry point stays focused on argument parsing + output routing.
|
|
7
|
+
*/
|
|
8
|
+
import { stringify as yamlStringify } from "yaml";
|
|
9
|
+
import { toErrorMessage, tryReadStdinText } from "../core/common";
|
|
10
|
+
import { loadConfig } from "../core/config";
|
|
11
|
+
import { UsageError } from "../core/errors";
|
|
12
|
+
import { warn } from "../core/warn";
|
|
13
|
+
/**
|
|
14
|
+
* Parse a shorthand duration string to a number of milliseconds.
|
|
15
|
+
* Supports: `30d` (days), `12h` (hours), `6m` (months, approximated as 30d).
|
|
16
|
+
*/
|
|
17
|
+
export function parseDuration(s) {
|
|
18
|
+
const match = s.trim().match(/^(\d+)([dhm])$/i);
|
|
19
|
+
if (!match)
|
|
20
|
+
throw new UsageError(`Invalid --expires format "${s}". Use shorthand like 30d, 12h, or 6m.`, "INVALID_FLAG_VALUE");
|
|
21
|
+
const n = Number(match[1]);
|
|
22
|
+
const unit = match[2].toLowerCase();
|
|
23
|
+
if (unit === "d")
|
|
24
|
+
return n * 24 * 60 * 60 * 1000;
|
|
25
|
+
if (unit === "h")
|
|
26
|
+
return n * 60 * 60 * 1000;
|
|
27
|
+
// 'm' = months, approximated as 30 days
|
|
28
|
+
return n * 30 * 24 * 60 * 60 * 1000;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Build a YAML frontmatter block from memory metadata.
|
|
32
|
+
*
|
|
33
|
+
* Uses `yaml.stringify` so values containing newlines, colons, or other
|
|
34
|
+
* YAML metacharacters are safely quoted. The previous implementation
|
|
35
|
+
* interpolated user input directly into `key: value` lines, which let a
|
|
36
|
+
* `description` containing `\n` + `tags: [x]` inject additional keys into
|
|
37
|
+
* the frontmatter — that is no longer possible here.
|
|
38
|
+
*
|
|
39
|
+
* Only includes fields that are present (non-empty).
|
|
40
|
+
*/
|
|
41
|
+
export function buildMemoryFrontmatter(fields) {
|
|
42
|
+
const obj = {};
|
|
43
|
+
if (fields.description?.trim())
|
|
44
|
+
obj.description = fields.description;
|
|
45
|
+
if (fields.tags && fields.tags.length > 0)
|
|
46
|
+
obj.tags = fields.tags;
|
|
47
|
+
if (fields.source?.trim())
|
|
48
|
+
obj.source = fields.source;
|
|
49
|
+
if (fields.observed_at?.trim())
|
|
50
|
+
obj.observed_at = fields.observed_at;
|
|
51
|
+
if (fields.expires?.trim())
|
|
52
|
+
obj.expires = fields.expires;
|
|
53
|
+
if (fields.subjective)
|
|
54
|
+
obj.subjective = true;
|
|
55
|
+
// No fields populated → emit a bare delimiter pair so callers don't
|
|
56
|
+
// produce `---\n{}\n---` (the YAML serializer's empty-object form).
|
|
57
|
+
if (Object.keys(obj).length === 0)
|
|
58
|
+
return "---\n---";
|
|
59
|
+
const serialized = yamlStringify(obj).trimEnd();
|
|
60
|
+
return `---\n${serialized}\n---`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Read memory content from the positional arg or stdin.
|
|
64
|
+
* Throws {@link UsageError} if neither is populated.
|
|
65
|
+
*/
|
|
66
|
+
export function readMemoryContent(contentArg) {
|
|
67
|
+
const content = contentArg ?? tryReadStdinText();
|
|
68
|
+
if (!content?.trim()) {
|
|
69
|
+
throw new UsageError("Memory content is required. Pass quoted text or pipe markdown into stdin.");
|
|
70
|
+
}
|
|
71
|
+
return content;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Run heuristic analysis on memory body text. Returns derived metadata
|
|
75
|
+
* fields without modifying any files. Pure TS, zero network, zero latency.
|
|
76
|
+
*/
|
|
77
|
+
export function runAutoHeuristics(body) {
|
|
78
|
+
const tags = [];
|
|
79
|
+
// Fenced code block present → tag "code"
|
|
80
|
+
if (/^```/m.test(body)) {
|
|
81
|
+
tags.push("code");
|
|
82
|
+
}
|
|
83
|
+
// First-person pronoun → subjective
|
|
84
|
+
const subjective = /\b(I|we|my|our)\b/.test(body) ? true : undefined;
|
|
85
|
+
// First URL-shaped token → source
|
|
86
|
+
const urlMatch = body.match(/https?:\/\/[^\s)>'"]+/);
|
|
87
|
+
const source = urlMatch ? urlMatch[0] : undefined;
|
|
88
|
+
// ISO date token or obvious relative date phrase → observed_at
|
|
89
|
+
const observed_at = detectObservedAt(body);
|
|
90
|
+
return { tags, source, observed_at, subjective };
|
|
91
|
+
}
|
|
92
|
+
const RELATIVE_DATE_OFFSETS = {
|
|
93
|
+
today: () => { },
|
|
94
|
+
yesterday: (d) => d.setDate(d.getDate() - 1),
|
|
95
|
+
"last week": (d) => d.setDate(d.getDate() - 7),
|
|
96
|
+
"last month": (d) => d.setMonth(d.getMonth() - 1),
|
|
97
|
+
};
|
|
98
|
+
function detectObservedAt(body) {
|
|
99
|
+
const isoMatch = body.match(/\b(\d{4}-\d{2}-\d{2})\b/);
|
|
100
|
+
if (isoMatch)
|
|
101
|
+
return isoMatch[1];
|
|
102
|
+
const relMatch = body.match(/\b(today|yesterday|last\s+week|last\s+month)\b/i);
|
|
103
|
+
if (!relMatch)
|
|
104
|
+
return undefined;
|
|
105
|
+
// Normalise the matched phrase: lowercase, collapse internal whitespace,
|
|
106
|
+
// so "last week" matches the lookup table key.
|
|
107
|
+
const phrase = relMatch[1].toLowerCase().replace(/\s+/g, " ");
|
|
108
|
+
const offset = RELATIVE_DATE_OFFSETS[phrase];
|
|
109
|
+
if (!offset)
|
|
110
|
+
return undefined;
|
|
111
|
+
const d = new Date();
|
|
112
|
+
offset(d);
|
|
113
|
+
return d.toISOString().slice(0, 10);
|
|
114
|
+
}
|
|
115
|
+
/** Hard timeout for the `--enrich` LLM call. Write-path must not block on a misbehaving endpoint. */
|
|
116
|
+
const LLM_ENRICH_TIMEOUT_MS = 10_000;
|
|
117
|
+
/**
|
|
118
|
+
* Attempt LLM enrichment of memory metadata. Returns merged metadata
|
|
119
|
+
* fields on success. On timeout, unreachable, or invalid JSON — returns
|
|
120
|
+
* empty result and emits a warning. Never throws; always resolves.
|
|
121
|
+
*/
|
|
122
|
+
export async function runLlmEnrich(body) {
|
|
123
|
+
const config = loadConfig();
|
|
124
|
+
if (!config.llm) {
|
|
125
|
+
warn("Warning: --enrich requires an LLM to be configured. Run `akm config set llm` to configure one.");
|
|
126
|
+
return { tags: [] };
|
|
127
|
+
}
|
|
128
|
+
const llmConfig = config.llm;
|
|
129
|
+
const { chatCompletion, parseJsonResponse } = await import("../llm/client");
|
|
130
|
+
const prompt = `You are a memory tagger for a developer knowledge base.
|
|
131
|
+
Given the memory text below, return ONLY a JSON object with these fields:
|
|
132
|
+
- "tags": array of 1-5 short lowercase keyword tags
|
|
133
|
+
- "description": one-sentence summary (optional)
|
|
134
|
+
- "observed_at": ISO date (YYYY-MM-DD) if the text references a specific date (optional)
|
|
135
|
+
|
|
136
|
+
Memory text:
|
|
137
|
+
${body.slice(0, 2000)}
|
|
138
|
+
|
|
139
|
+
Return ONLY the JSON object, no prose, no markdown fences.`;
|
|
140
|
+
try {
|
|
141
|
+
let timeoutHandle;
|
|
142
|
+
const result = await (async () => {
|
|
143
|
+
try {
|
|
144
|
+
return await Promise.race([
|
|
145
|
+
chatCompletion(llmConfig, [
|
|
146
|
+
{ role: "system", content: "Return only valid JSON. No prose." },
|
|
147
|
+
{ role: "user", content: prompt },
|
|
148
|
+
], { maxTokens: 256, temperature: 0.1 }),
|
|
149
|
+
new Promise((_, reject) => {
|
|
150
|
+
timeoutHandle = setTimeout(() => reject(new Error("LLM enrichment timed out")), LLM_ENRICH_TIMEOUT_MS);
|
|
151
|
+
}),
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
if (timeoutHandle !== undefined) {
|
|
156
|
+
clearTimeout(timeoutHandle);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
})();
|
|
160
|
+
const parsed = parseJsonResponse(result);
|
|
161
|
+
if (!parsed) {
|
|
162
|
+
warn("Warning: --enrich received invalid JSON from the LLM. Writing memory without enrichment.");
|
|
163
|
+
return { tags: [] };
|
|
164
|
+
}
|
|
165
|
+
const tags = Array.isArray(parsed.tags)
|
|
166
|
+
? parsed.tags.filter((t) => typeof t === "string" && t.trim().length > 0)
|
|
167
|
+
: [];
|
|
168
|
+
const description = typeof parsed.description === "string" && parsed.description.trim() ? parsed.description.trim() : undefined;
|
|
169
|
+
const observed_at = typeof parsed.observed_at === "string" && /^\d{4}-\d{2}-\d{2}$/.test(parsed.observed_at.trim())
|
|
170
|
+
? parsed.observed_at.trim()
|
|
171
|
+
: undefined;
|
|
172
|
+
return { tags, description, observed_at };
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
warn(`Warning: --enrich failed (${toErrorMessage(err)}). Writing memory without enrichment.`);
|
|
176
|
+
return { tags: [] };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -1,13 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `akm search` — entry point.
|
|
3
|
+
*
|
|
4
|
+
* Spec §6.1: search consults the local FTS5 index. There is one query path
|
|
5
|
+
* because there is one data store. Provider fan-out is gone.
|
|
6
|
+
*
|
|
7
|
+
* The orchestration here is thin: build the FTS query, optionally interleave
|
|
8
|
+
* a registry search behind `--source registry|both`, and log a usage event.
|
|
9
|
+
* Provider `search()` methods do not exist.
|
|
10
|
+
*/
|
|
11
|
+
import { loadConfig } from "../core/config";
|
|
12
|
+
import { UsageError } from "../core/errors";
|
|
13
|
+
import { closeDatabase, openDatabase } from "../indexer/db";
|
|
14
|
+
import { searchLocal } from "../indexer/db-search";
|
|
15
|
+
import { resolveSourceEntries } from "../indexer/search-source";
|
|
16
|
+
// Eagerly import source providers to trigger self-registration before the
|
|
17
|
+
// indexer or path-resolution code runs.
|
|
18
|
+
import "../sources/providers/index";
|
|
19
|
+
import { insertUsageEvent } from "../indexer/usage-events";
|
|
8
20
|
import { searchRegistry } from "./registry-search";
|
|
9
|
-
import { resolveStashSources } from "./search-source";
|
|
10
|
-
import { insertUsageEvent } from "./usage-events";
|
|
11
21
|
const DEFAULT_LIMIT = 20;
|
|
12
22
|
export async function akmSearch(input) {
|
|
13
23
|
const t0 = Date.now();
|
|
@@ -17,7 +27,7 @@ export async function akmSearch(input) {
|
|
|
17
27
|
const limit = normalizeLimit(input.limit);
|
|
18
28
|
const source = parseSearchSource(input.source ?? "stash");
|
|
19
29
|
const config = loadConfig();
|
|
20
|
-
const sources =
|
|
30
|
+
const sources = resolveSourceEntries(undefined, config);
|
|
21
31
|
if (sources.length === 0) {
|
|
22
32
|
// stashDir: "" is a safe sentinel here — the response carries zero hits
|
|
23
33
|
// and a warning, so no downstream code will try to use the empty path.
|
|
@@ -35,10 +45,6 @@ export async function akmSearch(input) {
|
|
|
35
45
|
// Primary stash directory — used for DB path lookups and as the default
|
|
36
46
|
// stash root. Safe because the empty-sources case is handled above.
|
|
37
47
|
const stashDir = sources[0].path;
|
|
38
|
-
// Resolve additional stash providers (e.g. OpenViking) from config.
|
|
39
|
-
// Exclude filesystem (handled by resolveStashSources) and context-hub/github
|
|
40
|
-
// (content now indexed through the unified FTS5 pipeline).
|
|
41
|
-
const additionalStashProviders = resolveStashProviders(config).filter((p) => p.type !== "filesystem" && p.type !== "context-hub" && p.type !== "git");
|
|
42
48
|
const localResult = source === "registry"
|
|
43
49
|
? undefined
|
|
44
50
|
: await searchLocal({
|
|
@@ -49,35 +55,17 @@ export async function akmSearch(input) {
|
|
|
49
55
|
sources,
|
|
50
56
|
config,
|
|
51
57
|
});
|
|
52
|
-
// Pass original case to providers — FTS5 requires lowercase but remote providers handle case themselves
|
|
53
|
-
const additionalStashResults = source === "registry" || additionalStashProviders.length === 0
|
|
54
|
-
? []
|
|
55
|
-
: await Promise.all(additionalStashProviders.map(async (provider) => {
|
|
56
|
-
try {
|
|
57
|
-
return await provider.search({ query, type: searchType === "any" ? undefined : searchType, limit });
|
|
58
|
-
}
|
|
59
|
-
catch (err) {
|
|
60
|
-
return {
|
|
61
|
-
hits: [],
|
|
62
|
-
warnings: [`Stash ${provider.name}: ${err instanceof Error ? err.message : String(err)}`],
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
}));
|
|
66
|
-
// Merge stash hits from all providers
|
|
67
|
-
const additionalHits = additionalStashResults.flatMap((r) => r.hits);
|
|
68
|
-
const additionalWarnings = additionalStashResults.flatMap((r) => r.warnings ?? []);
|
|
69
58
|
const registryResult = source === "stash" ? undefined : await searchRegistry(query, { limit, registries: config.registries });
|
|
70
59
|
if (source === "stash") {
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
const hasResults = allStashHits.length > 0;
|
|
60
|
+
const localHits = localResult?.hits ?? [];
|
|
61
|
+
const hasResults = localHits.length > 0;
|
|
74
62
|
const response = {
|
|
75
63
|
schemaVersion: 1,
|
|
76
64
|
stashDir,
|
|
77
65
|
source,
|
|
78
|
-
hits:
|
|
66
|
+
hits: localHits,
|
|
79
67
|
tip: hasResults ? undefined : localResult?.tip,
|
|
80
|
-
warnings:
|
|
68
|
+
warnings: localResult?.warnings?.length ? localResult.warnings : undefined,
|
|
81
69
|
timing: { totalMs: Date.now() - t0, rankMs: localResult?.rankMs, embedMs: localResult?.embedMs },
|
|
82
70
|
};
|
|
83
71
|
logSearchEvent(query, response);
|
|
@@ -116,14 +104,14 @@ export async function akmSearch(input) {
|
|
|
116
104
|
return response;
|
|
117
105
|
}
|
|
118
106
|
// source === "both"
|
|
119
|
-
const allStashHits =
|
|
120
|
-
const warnings = [...(localResult?.warnings ?? []), ...
|
|
107
|
+
const allStashHits = (localResult?.hits ?? []).slice(0, limit);
|
|
108
|
+
const warnings = [...(localResult?.warnings ?? []), ...(registryResult?.warnings ?? [])];
|
|
121
109
|
const hasResults = allStashHits.length > 0 || registryHits.length > 0;
|
|
122
110
|
const response = {
|
|
123
111
|
schemaVersion: 1,
|
|
124
112
|
stashDir,
|
|
125
113
|
source,
|
|
126
|
-
hits: allStashHits
|
|
114
|
+
hits: allStashHits,
|
|
127
115
|
registryHits,
|
|
128
116
|
tip: hasResults ? undefined : "No matching stash assets or registry entries were found.",
|
|
129
117
|
warnings: warnings.length ? warnings : undefined,
|
|
@@ -184,35 +172,6 @@ function logSearchEvent(query, response, existingDb) {
|
|
|
184
172
|
}
|
|
185
173
|
}
|
|
186
174
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
187
|
-
/**
|
|
188
|
-
* Merge local and additional stash hits into a single ranked list.
|
|
189
|
-
*
|
|
190
|
-
* Provider hits (e.g. OpenViking) keep their original scores and compete
|
|
191
|
-
* fairly alongside local hits. Duplicates are resolved in favour of the
|
|
192
|
-
* local version.
|
|
193
|
-
*
|
|
194
|
-
* 1. Build set of local hit keys for dedup.
|
|
195
|
-
* 2. Filter provider hits that aren't duplicates.
|
|
196
|
-
* 3. Combine local + non-duplicate provider hits.
|
|
197
|
-
* 4. Sort by score descending.
|
|
198
|
-
* 5. Slice to limit.
|
|
199
|
-
*/
|
|
200
|
-
export function mergeStashHits(localHits, additionalHits, limit) {
|
|
201
|
-
if (additionalHits.length === 0)
|
|
202
|
-
return localHits.slice(0, limit);
|
|
203
|
-
// Track local hits by a dedup key (path > ref > name)
|
|
204
|
-
const localKeys = new Set();
|
|
205
|
-
for (const h of localHits) {
|
|
206
|
-
localKeys.add(h.path ?? h.ref ?? h.name);
|
|
207
|
-
}
|
|
208
|
-
// Keep non-duplicate provider hits with their original scores
|
|
209
|
-
const providerOnly = additionalHits.filter((h) => {
|
|
210
|
-
const key = h.path ?? h.ref ?? h.name;
|
|
211
|
-
return !localKeys.has(key);
|
|
212
|
-
});
|
|
213
|
-
// Combine and sort by score descending
|
|
214
|
-
return [...localHits, ...providerOnly].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, limit);
|
|
215
|
-
}
|
|
216
175
|
function normalizeLimit(limit) {
|
|
217
176
|
if (typeof limit !== "number" || Number.isNaN(limit) || limit <= 0) {
|
|
218
177
|
return DEFAULT_LIMIT;
|
|
@@ -227,7 +186,7 @@ export function parseSearchSource(source) {
|
|
|
227
186
|
return "stash";
|
|
228
187
|
if (typeof source === "undefined")
|
|
229
188
|
return "stash";
|
|
230
|
-
throw new UsageError(`Invalid value for --source: ${String(source)}. Expected one of: stash|registry|both
|
|
189
|
+
throw new UsageError(`Invalid value for --source: ${String(source)}. Expected one of: stash|registry|both`, "INVALID_SOURCE_VALUE");
|
|
231
190
|
}
|
|
232
191
|
/**
|
|
233
192
|
* Merge stash hits and registry hits via simple concatenation.
|
|
@@ -2,8 +2,8 @@ import * as childProcess from "node:child_process";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { fetchWithRetry, IS_WINDOWS } from "
|
|
6
|
-
import { githubHeaders } from "
|
|
5
|
+
import { fetchWithRetry, IS_WINDOWS } from "../core/common";
|
|
6
|
+
import { githubHeaders } from "../integrations/github";
|
|
7
7
|
const REPO = "itlackey/akm";
|
|
8
8
|
const DEFAULT_PACKAGE_NAME = "akm-cli";
|
|
9
9
|
const NODE_MODULES_SEGMENT = "/node_modules/";
|
|
@@ -303,7 +303,7 @@ function normalizePathSeparators(value) {
|
|
|
303
303
|
}
|
|
304
304
|
function getInstalledPackageName() {
|
|
305
305
|
try {
|
|
306
|
-
const pkgPath = path.resolve(import.meta.dir ?? __dirname, "
|
|
306
|
+
const pkgPath = path.resolve(import.meta.dir ?? __dirname, "../../package.json");
|
|
307
307
|
if (fs.existsSync(pkgPath)) {
|
|
308
308
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
309
309
|
if (typeof pkg.name === "string" && pkg.name.trim()) {
|
|
@@ -1,31 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `akm show` — entry point.
|
|
3
|
+
*
|
|
4
|
+
* Spec §6.2:
|
|
5
|
+
*
|
|
6
|
+
* show(ref) → indexer.lookup(ref) → readFile(entry.filePath)
|
|
7
|
+
*
|
|
8
|
+
* The richer presentation logic (matchers, renderers, wiki-root handling,
|
|
9
|
+
* edit-hints, summary-detail truncation) lives below in this file. The flow:
|
|
10
|
+
*
|
|
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`.
|
|
16
|
+
* 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
|
+
*/
|
|
1
22
|
import fs from "node:fs";
|
|
2
23
|
import path from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { NotFoundError, UsageError } from "
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
import "
|
|
24
|
+
import { parseAssetRef } from "../core/asset-ref";
|
|
25
|
+
import { loadConfig } from "../core/config";
|
|
26
|
+
import { NotFoundError, UsageError } from "../core/errors";
|
|
27
|
+
import { parseFrontmatter, toStringOrUndefined } from "../core/frontmatter";
|
|
28
|
+
import { closeDatabase, openDatabase } from "../indexer/db";
|
|
29
|
+
import { buildFileContext, buildRenderContext, getRenderer, runMatchers } from "../indexer/file-context";
|
|
30
|
+
import { lookup } from "../indexer/indexer";
|
|
31
|
+
import { loadStashFile } from "../indexer/metadata";
|
|
32
|
+
import { buildEditHint, findSourceForPath, isEditable, resolveSourceEntries } from "../indexer/search-source";
|
|
33
|
+
import { resolveSourcesForOrigin } from "../registry/origin-resolve";
|
|
34
|
+
// Eagerly import source providers to trigger self-registration.
|
|
35
|
+
import "../sources/providers/index";
|
|
36
|
+
import { insertUsageEvent } from "../indexer/usage-events";
|
|
37
|
+
import { resolveAssetPath } from "../sources/resolve";
|
|
17
38
|
/**
|
|
18
39
|
* Show a wiki root (no page path) — returns the same payload as
|
|
19
40
|
* `akm wiki show <name>`.
|
|
20
|
-
*
|
|
21
|
-
* Called when `parseAssetRef` yields `type === "wiki"` and the name has no
|
|
22
|
-
* `/`, e.g. `wiki:research`.
|
|
23
41
|
*/
|
|
24
42
|
async function showWikiRoot(stashDir, wikiName) {
|
|
25
|
-
const { showWiki } = await import("
|
|
43
|
+
const { showWiki } = await import("../wiki/wiki.js");
|
|
26
44
|
const result = showWiki(stashDir, wikiName);
|
|
27
|
-
// Shape the WikiShowResult into a ShowResponse-compatible object.
|
|
28
|
-
// The payload mirrors what `akm wiki show <name>` returns.
|
|
29
45
|
return {
|
|
30
46
|
type: "wiki",
|
|
31
47
|
name: result.ref,
|
|
@@ -40,7 +56,7 @@ async function showWikiRoot(stashDir, wikiName) {
|
|
|
40
56
|
};
|
|
41
57
|
}
|
|
42
58
|
async function showWikiRootForSource(stashDir, source, wikiName) {
|
|
43
|
-
const { showWikiAtPath } = await import("
|
|
59
|
+
const { showWikiAtPath } = await import("../wiki/wiki.js");
|
|
44
60
|
if (source.wikiName === wikiName) {
|
|
45
61
|
const result = showWikiAtPath(wikiName, source.path);
|
|
46
62
|
return {
|
|
@@ -66,19 +82,21 @@ function resolveRegisteredWikiAssetPath(wikiRoot, wikiName, assetName) {
|
|
|
66
82
|
const candidate = path.resolve(wikiRoot, `${pageName}.md`);
|
|
67
83
|
const resolvedRoot = fs.realpathSync(wikiRoot);
|
|
68
84
|
if (!candidate.startsWith(resolvedRoot + path.sep)) {
|
|
69
|
-
throw new UsageError("Ref resolves outside the stash root.");
|
|
85
|
+
throw new UsageError("Ref resolves outside the stash root.", "PATH_ESCAPE_VIOLATION");
|
|
70
86
|
}
|
|
71
87
|
if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
|
|
72
88
|
throw new NotFoundError(`Stash asset not found for ref: wiki:${assetName}`);
|
|
73
89
|
}
|
|
74
90
|
const realTarget = fs.realpathSync(candidate);
|
|
75
91
|
if (!realTarget.startsWith(resolvedRoot + path.sep)) {
|
|
76
|
-
throw new UsageError("Ref resolves outside the stash root.");
|
|
92
|
+
throw new UsageError("Ref resolves outside the stash root.", "PATH_ESCAPE_VIOLATION");
|
|
77
93
|
}
|
|
78
94
|
return realTarget;
|
|
79
95
|
}
|
|
80
96
|
/**
|
|
81
|
-
* Unified show:
|
|
97
|
+
* Unified show: queries the local FTS5 index, then falls back to on-disk
|
|
98
|
+
* type-dir resolution if the index has no row. Spec §6.2; no remote provider
|
|
99
|
+
* fallback.
|
|
82
100
|
*
|
|
83
101
|
* When `detail` is `"summary"`, the response omits content/template/prompt and
|
|
84
102
|
* returns only compact metadata (name, type, description, tags, parameters).
|
|
@@ -92,7 +110,7 @@ export async function akmShowUnified(input) {
|
|
|
92
110
|
{
|
|
93
111
|
const parsed = parseAssetRef(ref);
|
|
94
112
|
if (parsed.type === "wiki" && !parsed.name.includes("/")) {
|
|
95
|
-
const allSources =
|
|
113
|
+
const allSources = resolveSourceEntries();
|
|
96
114
|
const searchSources = resolveSourcesForOrigin(parsed.origin, allSources);
|
|
97
115
|
let lastError;
|
|
98
116
|
for (const source of searchSources) {
|
|
@@ -109,43 +127,11 @@ export async function akmShowUnified(input) {
|
|
|
109
127
|
new NotFoundError(`Wiki not found: ${parsed.name}. Run \`akm wiki create ${parsed.name}\` to create it.`));
|
|
110
128
|
}
|
|
111
129
|
}
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
logShowEvent(ref);
|
|
117
|
-
return result;
|
|
118
|
-
}
|
|
119
|
-
catch (err) {
|
|
120
|
-
// Only fall through to remote providers on NotFoundError
|
|
121
|
-
if (!(err instanceof NotFoundError))
|
|
122
|
-
throw err;
|
|
123
|
-
localError = err;
|
|
124
|
-
}
|
|
125
|
-
// 2. Try remote providers (e.g. OpenViking)
|
|
126
|
-
const config = loadConfig();
|
|
127
|
-
const providers = resolveStashProviders(config).filter((p) => p.type !== "filesystem" && p.canShow(ref));
|
|
128
|
-
for (const provider of providers) {
|
|
129
|
-
try {
|
|
130
|
-
const response = await provider.show(ref, input.view);
|
|
131
|
-
logShowEvent(ref);
|
|
132
|
-
if (input.detail === "summary") {
|
|
133
|
-
return buildSummaryResponse(response);
|
|
134
|
-
}
|
|
135
|
-
return response;
|
|
136
|
-
}
|
|
137
|
-
catch (err) {
|
|
138
|
-
if (!(err instanceof NotFoundError))
|
|
139
|
-
throw err;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
// Nothing found anywhere — rethrow the original local error with its specific message
|
|
143
|
-
throw localError;
|
|
130
|
+
// Try local filesystem (FTS5 index lookup, then on-disk fallback)
|
|
131
|
+
const result = await showLocal(input);
|
|
132
|
+
logShowEvent(ref);
|
|
133
|
+
return result;
|
|
144
134
|
}
|
|
145
|
-
/**
|
|
146
|
-
* Fire-and-forget: log a show event to the usage_events table.
|
|
147
|
-
* Never blocks the caller; errors are silently ignored.
|
|
148
|
-
*/
|
|
149
135
|
function logShowEvent(ref, existingDb) {
|
|
150
136
|
try {
|
|
151
137
|
const db = existingDb ?? openDatabase();
|
|
@@ -170,14 +156,48 @@ function logShowEvent(ref, existingDb) {
|
|
|
170
156
|
/* fire-and-forget */
|
|
171
157
|
}
|
|
172
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Resolve an asset path to a file via:
|
|
161
|
+
* 1. `indexer.lookup(ref)` — the spec's primary path (§6.2).
|
|
162
|
+
* 2. On-disk type-dir traversal — fallback for files not yet indexed.
|
|
163
|
+
*
|
|
164
|
+
* Returns `undefined` if neither path finds a match.
|
|
165
|
+
*/
|
|
166
|
+
async function resolvePathViaIndexThenDisk(parsed, searchSourceDirs) {
|
|
167
|
+
// Step 1: indexer
|
|
168
|
+
try {
|
|
169
|
+
const entry = await lookup(parsed);
|
|
170
|
+
if (entry) {
|
|
171
|
+
return { assetPath: entry.filePath };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
// Index unavailable (e.g. DB doesn't exist yet) — fall back to disk walk.
|
|
176
|
+
if (!(err instanceof NotFoundError)) {
|
|
177
|
+
// continue to disk fallback
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Step 2: on-disk type-dir traversal
|
|
181
|
+
let lastError;
|
|
182
|
+
for (const dir of searchSourceDirs) {
|
|
183
|
+
try {
|
|
184
|
+
const assetPath = await resolveAssetPath(dir, parsed.type, parsed.name);
|
|
185
|
+
return { assetPath, lastError };
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return lastError ? { assetPath: "", lastError } : undefined;
|
|
192
|
+
}
|
|
173
193
|
/** @internal Use akmShowUnified() for all external callers. */
|
|
174
194
|
export async function showLocal(input) {
|
|
175
195
|
const parsed = parseAssetRef(input.ref);
|
|
176
196
|
const displayType = parsed.type;
|
|
177
197
|
const config = loadConfig();
|
|
178
|
-
const allSources =
|
|
198
|
+
const allSources = resolveSourceEntries(input.stashDir);
|
|
179
199
|
const searchSources = resolveSourcesForOrigin(parsed.origin, allSources);
|
|
180
|
-
const
|
|
200
|
+
const allSourceDirs = searchSources.map((s) => s.path);
|
|
181
201
|
let assetPath;
|
|
182
202
|
const matchedSource = parsed.type === "wiki" ? searchSources.find((source) => parsed.name.startsWith(`${source.wikiName}/`)) : undefined;
|
|
183
203
|
let lastError;
|
|
@@ -189,21 +209,19 @@ export async function showLocal(input) {
|
|
|
189
209
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
190
210
|
}
|
|
191
211
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
assetPath = await resolveAssetPath(dir, parsed.type, parsed.name);
|
|
197
|
-
break;
|
|
212
|
+
if (!assetPath) {
|
|
213
|
+
const resolved = await resolvePathViaIndexThenDisk(parsed, allSourceDirs);
|
|
214
|
+
if (resolved?.assetPath) {
|
|
215
|
+
assetPath = resolved.assetPath;
|
|
198
216
|
}
|
|
199
|
-
|
|
200
|
-
lastError =
|
|
217
|
+
else if (resolved?.lastError) {
|
|
218
|
+
lastError = resolved.lastError;
|
|
201
219
|
}
|
|
202
220
|
}
|
|
203
221
|
if (!assetPath && parsed.origin && searchSources.length === 0) {
|
|
204
222
|
const installCmd = `akm add ${parsed.origin}`;
|
|
205
223
|
throw new NotFoundError(`Stash asset not found for ref: ${displayType}:${parsed.name}. ` +
|
|
206
|
-
`
|
|
224
|
+
`Stash "${parsed.origin}" is not installed. Run: ${installCmd}`);
|
|
207
225
|
}
|
|
208
226
|
if (!assetPath) {
|
|
209
227
|
throw (lastError ??
|
|
@@ -211,7 +229,7 @@ export async function showLocal(input) {
|
|
|
211
229
|
"Check the name with `akm search` or verify the asset exists in your stash."));
|
|
212
230
|
}
|
|
213
231
|
const source = matchedSource ?? findSourceForPath(assetPath, allSources);
|
|
214
|
-
const sourceStashDir = source?.path ??
|
|
232
|
+
const sourceStashDir = source?.path ?? allSourceDirs[0];
|
|
215
233
|
if (!sourceStashDir) {
|
|
216
234
|
throw new UsageError(`Could not determine stash root for asset: ${displayType}:${parsed.name}. ` +
|
|
217
235
|
"Run `akm init` to create the stash directory, or check `akm stash list` for configured paths.");
|
|
@@ -229,7 +247,7 @@ export async function showLocal(input) {
|
|
|
229
247
|
if (!renderer) {
|
|
230
248
|
throw new UsageError(`Renderer "${match.renderer}" not found for asset: ${displayType}:${parsed.name}`);
|
|
231
249
|
}
|
|
232
|
-
const renderCtx = buildRenderContext(fileCtx, match,
|
|
250
|
+
const renderCtx = buildRenderContext(fileCtx, match, allSourceDirs, source?.registryId);
|
|
233
251
|
const response = renderer.buildShowResponse(renderCtx);
|
|
234
252
|
const editable = isEditable(assetPath, config);
|
|
235
253
|
const fullResponse = {
|
|
@@ -243,6 +261,20 @@ export async function showLocal(input) {
|
|
|
243
261
|
}
|
|
244
262
|
return fullResponse;
|
|
245
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Minimal `show`: ref → indexer lookup → file contents. Used by callers that
|
|
266
|
+
* just need the raw file (e.g. clone, write-source) and don't want the full
|
|
267
|
+
* renderer graph. Spec §6.2's literal flow.
|
|
268
|
+
*/
|
|
269
|
+
export async function showByRef(ref) {
|
|
270
|
+
const parsed = parseAssetRef(ref);
|
|
271
|
+
const entry = await lookup(parsed);
|
|
272
|
+
if (!entry) {
|
|
273
|
+
throw new NotFoundError(`Asset not found for ref: ${parsed.type}:${parsed.name}`);
|
|
274
|
+
}
|
|
275
|
+
const body = await fs.promises.readFile(entry.filePath, "utf8");
|
|
276
|
+
return { filePath: entry.filePath, body };
|
|
277
|
+
}
|
|
246
278
|
/**
|
|
247
279
|
* Build a compact summary response from a full ShowResponse.
|
|
248
280
|
*
|
|
@@ -250,24 +282,17 @@ export async function showLocal(input) {
|
|
|
250
282
|
* type, name, path, description, tags, parameters, action.
|
|
251
283
|
* Enriches description and tags from frontmatter or .stash.json when available.
|
|
252
284
|
*
|
|
253
|
-
* Enrichment via frontmatter and .stash.json is only performed when `assetPath`
|
|
254
|
-
* is supplied (local assets). Remote provider responses (e.g. OpenViking) rely
|
|
255
|
-
* on the provider having already populated description and tags.
|
|
256
|
-
*
|
|
257
285
|
* The resulting JSON should be under 200 tokens.
|
|
258
286
|
*/
|
|
259
287
|
function buildSummaryResponse(full, assetPath) {
|
|
260
|
-
// Try to enrich metadata from .stash.json if description or tags are missing
|
|
261
288
|
let description = full.description;
|
|
262
289
|
let tags = full.tags;
|
|
263
290
|
if (assetPath) {
|
|
264
|
-
// Try frontmatter extraction from content fields
|
|
265
291
|
const textContent = full.content ?? full.template ?? full.prompt;
|
|
266
292
|
if (textContent && !description) {
|
|
267
293
|
const parsed = parseFrontmatter(textContent);
|
|
268
294
|
description = toStringOrUndefined(parsed.data.description);
|
|
269
295
|
}
|
|
270
|
-
// Try .stash.json for richer metadata (tags especially)
|
|
271
296
|
const dir = path.dirname(assetPath);
|
|
272
297
|
const stashFile = loadStashFile(dir);
|
|
273
298
|
if (stashFile) {
|