akm-cli 0.5.0 → 0.6.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.
- package/CHANGELOG.md +32 -5
- package/dist/asset-registry.js +29 -5
- package/dist/asset-spec.js +12 -5
- package/dist/cli-hints.js +300 -0
- package/dist/cli.js +218 -1357
- package/dist/common.js +147 -50
- package/dist/config.js +224 -13
- package/dist/create-provider-registry.js +1 -1
- package/dist/curate.js +258 -0
- package/dist/{local-search.js → db-search.js} +30 -19
- package/dist/db.js +168 -62
- package/dist/embedder.js +49 -273
- package/dist/embedders/cache.js +47 -0
- package/dist/embedders/local.js +152 -0
- package/dist/embedders/remote.js +121 -0
- package/dist/embedders/types.js +39 -0
- package/dist/errors.js +14 -3
- package/dist/frontmatter.js +61 -7
- package/dist/indexer.js +38 -7
- package/dist/info.js +2 -2
- package/dist/install-audit.js +16 -1
- package/dist/{installed-kits.js → installed-stashes.js} +48 -22
- package/dist/llm-client.js +92 -0
- package/dist/llm.js +14 -126
- package/dist/lockfile.js +28 -1
- package/dist/matchers.js +1 -1
- package/dist/metadata-enhance.js +53 -0
- package/dist/migration-help.js +75 -44
- package/dist/output-context.js +77 -0
- package/dist/output-shapes.js +198 -0
- package/dist/output-text.js +520 -0
- package/dist/paths.js +4 -4
- package/dist/providers/index.js +11 -0
- package/dist/providers/skills-sh.js +1 -1
- package/dist/providers/static-index.js +47 -45
- package/dist/registry-build-index.js +36 -29
- package/dist/registry-factory.js +2 -2
- package/dist/registry-resolve.js +8 -4
- package/dist/registry-search.js +62 -5
- package/dist/remember.js +172 -0
- package/dist/renderers.js +52 -0
- package/dist/search-source.js +73 -42
- package/dist/setup-steps.js +45 -0
- package/dist/setup.js +149 -76
- package/dist/stash-add.js +94 -38
- package/dist/stash-clone.js +4 -4
- package/dist/stash-provider-factory.js +2 -2
- package/dist/stash-provider.js +3 -1
- package/dist/stash-providers/filesystem.js +31 -1
- package/dist/stash-providers/git.js +209 -8
- package/dist/stash-providers/index.js +1 -0
- package/dist/stash-providers/npm.js +159 -0
- package/dist/stash-providers/provider-utils.js +162 -0
- package/dist/stash-providers/sync-from-ref.js +45 -0
- package/dist/stash-providers/tar-utils.js +151 -0
- package/dist/stash-providers/website.js +80 -4
- package/dist/stash-resolve.js +5 -5
- package/dist/stash-search.js +4 -4
- package/dist/stash-show.js +3 -3
- package/dist/wiki.js +6 -6
- package/dist/workflow-authoring.js +12 -4
- package/dist/workflow-markdown.js +9 -0
- package/dist/workflow-runs.js +12 -2
- 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 +29 -0
- package/docs/migration/release-notes/README.md +21 -0
- package/package.json +3 -2
- package/dist/registry-install.js +0 -532
- /package/dist/{kit-include.js → stash-include.js} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { fetchWithRetry, toErrorMessage } from "../common";
|
|
3
|
+
import { fetchWithRetry, jsonWithByteCap, toErrorMessage } from "../common";
|
|
4
4
|
import { asString } from "../github";
|
|
5
5
|
import { getRegistryIndexCacheDir } from "../paths";
|
|
6
6
|
import { registerProvider } from "../registry-factory";
|
|
@@ -23,8 +23,8 @@ class StaticIndexProvider {
|
|
|
23
23
|
const index = await loadIndex(this.config);
|
|
24
24
|
if (index) {
|
|
25
25
|
const regName = this.config.name;
|
|
26
|
-
for (const
|
|
27
|
-
allKits.push({
|
|
26
|
+
for (const stash of index.stashes) {
|
|
27
|
+
allKits.push({ stash, registryName: regName });
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
}
|
|
@@ -58,7 +58,9 @@ async function loadIndex(entry) {
|
|
|
58
58
|
if (!response.ok) {
|
|
59
59
|
throw new Error(`HTTP ${response.status}`);
|
|
60
60
|
}
|
|
61
|
-
|
|
61
|
+
// Cap at 50 MB — registry indexes can grow large but unbounded
|
|
62
|
+
// responses from a compromised server would OOM us.
|
|
63
|
+
const data = await jsonWithByteCap(response, 50 * 1024 * 1024);
|
|
62
64
|
const index = parseRegistryIndex(data);
|
|
63
65
|
if (index) {
|
|
64
66
|
writeCachedIndex(cachePath, index);
|
|
@@ -120,20 +122,20 @@ export function parseRegistryIndex(data) {
|
|
|
120
122
|
if (typeof data !== "object" || data === null || Array.isArray(data))
|
|
121
123
|
return null;
|
|
122
124
|
const obj = data;
|
|
123
|
-
if (typeof obj.version !== "number" ||
|
|
125
|
+
if (typeof obj.version !== "number" || obj.version !== 3)
|
|
124
126
|
return null;
|
|
125
127
|
if (typeof obj.updatedAt !== "string")
|
|
126
128
|
return null;
|
|
127
|
-
if (!Array.isArray(obj.
|
|
129
|
+
if (!Array.isArray(obj.stashes))
|
|
128
130
|
return null;
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
return
|
|
131
|
+
const stashes = obj.stashes.flatMap((raw) => {
|
|
132
|
+
const stash = parseStashEntry(raw);
|
|
133
|
+
return stash ? [stash] : [];
|
|
132
134
|
});
|
|
133
|
-
return { version: obj.version, updatedAt: obj.updatedAt,
|
|
135
|
+
return { version: obj.version, updatedAt: obj.updatedAt, stashes };
|
|
134
136
|
}
|
|
135
|
-
// ──
|
|
136
|
-
function
|
|
137
|
+
// ── Stash entry parsing ───────────────────────────────────────────────────────
|
|
138
|
+
function parseStashEntry(raw) {
|
|
137
139
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
138
140
|
return null;
|
|
139
141
|
const obj = raw;
|
|
@@ -160,23 +162,23 @@ function parseKitEntry(raw) {
|
|
|
160
162
|
};
|
|
161
163
|
}
|
|
162
164
|
// ── Scoring ─────────────────────────────────────────────────────────────────
|
|
163
|
-
function scoreKits(
|
|
165
|
+
function scoreKits(stashes, query, limit) {
|
|
164
166
|
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
165
167
|
const scored = [];
|
|
166
|
-
for (const {
|
|
167
|
-
const score =
|
|
168
|
+
for (const { stash, registryName } of stashes) {
|
|
169
|
+
const score = scoreStash(stash, tokens);
|
|
168
170
|
if (score > 0) {
|
|
169
|
-
scored.push({
|
|
171
|
+
scored.push({ stash, registryName, score });
|
|
170
172
|
}
|
|
171
173
|
}
|
|
172
174
|
scored.sort((a, b) => b.score - a.score);
|
|
173
|
-
return scored.slice(0, limit).map(({
|
|
175
|
+
return scored.slice(0, limit).map(({ stash, registryName, score }) => toSearchHit(stash, score, registryName));
|
|
174
176
|
}
|
|
175
|
-
function
|
|
177
|
+
function scoreStash(stash, tokens) {
|
|
176
178
|
let score = 0;
|
|
177
|
-
const nameLower =
|
|
178
|
-
const descLower = (
|
|
179
|
-
const tagsLower = (
|
|
179
|
+
const nameLower = stash.name.toLowerCase();
|
|
180
|
+
const descLower = (stash.description ?? "").toLowerCase();
|
|
181
|
+
const tagsLower = (stash.tags ?? []).map((t) => t.toLowerCase());
|
|
180
182
|
for (const token of tokens) {
|
|
181
183
|
// Exact name match is strongest signal
|
|
182
184
|
if (nameLower === token) {
|
|
@@ -197,34 +199,34 @@ function scoreKit(kit, tokens) {
|
|
|
197
199
|
score += 0.2;
|
|
198
200
|
}
|
|
199
201
|
// Author match
|
|
200
|
-
if (
|
|
202
|
+
if (stash.author?.toLowerCase().includes(token)) {
|
|
201
203
|
score += 0.15;
|
|
202
204
|
}
|
|
203
205
|
}
|
|
204
206
|
// Normalize by token count so multi-word queries don't inflate scores
|
|
205
207
|
return tokens.length > 0 ? score / tokens.length : 0;
|
|
206
208
|
}
|
|
207
|
-
function toSearchHit(
|
|
209
|
+
function toSearchHit(stash, score, registryName) {
|
|
208
210
|
const metadata = {};
|
|
209
|
-
if (
|
|
210
|
-
metadata.version =
|
|
211
|
-
if (
|
|
212
|
-
metadata.author =
|
|
213
|
-
if (
|
|
214
|
-
metadata.license =
|
|
215
|
-
if (
|
|
216
|
-
metadata.assetTypes =
|
|
211
|
+
if (stash.latestVersion)
|
|
212
|
+
metadata.version = stash.latestVersion;
|
|
213
|
+
if (stash.author)
|
|
214
|
+
metadata.author = stash.author;
|
|
215
|
+
if (stash.license)
|
|
216
|
+
metadata.license = stash.license;
|
|
217
|
+
if (stash.assetTypes?.length)
|
|
218
|
+
metadata.assetTypes = stash.assetTypes.join(", ");
|
|
217
219
|
return {
|
|
218
|
-
source:
|
|
219
|
-
id:
|
|
220
|
-
title:
|
|
221
|
-
description:
|
|
222
|
-
ref:
|
|
223
|
-
installRef: buildInstallRef(
|
|
224
|
-
homepage:
|
|
220
|
+
source: stash.source,
|
|
221
|
+
id: stash.id,
|
|
222
|
+
title: stash.name,
|
|
223
|
+
description: stash.description,
|
|
224
|
+
ref: stash.ref,
|
|
225
|
+
installRef: buildInstallRef(stash.source, stash.ref),
|
|
226
|
+
homepage: stash.homepage,
|
|
225
227
|
score: Math.round(score * 1000) / 1000,
|
|
226
228
|
metadata,
|
|
227
|
-
curated:
|
|
229
|
+
curated: stash.curated,
|
|
228
230
|
registryName,
|
|
229
231
|
};
|
|
230
232
|
}
|
|
@@ -255,16 +257,16 @@ function parseAssetEntry(raw) {
|
|
|
255
257
|
};
|
|
256
258
|
}
|
|
257
259
|
// ── Asset-level scoring ─────────────────────────────────────────────────────
|
|
258
|
-
function scoreAssets(
|
|
260
|
+
function scoreAssets(stashes, query, limit) {
|
|
259
261
|
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
260
262
|
if (tokens.length === 0)
|
|
261
263
|
return [];
|
|
262
264
|
const scored = [];
|
|
263
|
-
for (const {
|
|
264
|
-
if (!
|
|
265
|
+
for (const { stash, registryName } of stashes) {
|
|
266
|
+
if (!stash.assets || stash.assets.length === 0)
|
|
265
267
|
continue;
|
|
266
|
-
const installRef = buildInstallRef(
|
|
267
|
-
for (const asset of
|
|
268
|
+
const installRef = buildInstallRef(stash.source, stash.ref);
|
|
269
|
+
for (const asset of stash.assets) {
|
|
268
270
|
const score = scoreAsset(asset, tokens);
|
|
269
271
|
if (score > 0) {
|
|
270
272
|
scored.push({
|
|
@@ -274,7 +276,7 @@ function scoreAssets(kits, query, limit) {
|
|
|
274
276
|
assetName: asset.name,
|
|
275
277
|
description: asset.description,
|
|
276
278
|
estimatedTokens: asset.estimatedTokens,
|
|
277
|
-
|
|
279
|
+
stash: { id: stash.id, name: stash.name },
|
|
278
280
|
registryName,
|
|
279
281
|
action: `akm add ${installRef}`,
|
|
280
282
|
score: Math.round(score * 1000) / 1000,
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { fetchWithRetry } from "./common";
|
|
4
|
+
import { fetchWithRetry, jsonWithByteCap } from "./common";
|
|
5
5
|
import { asRecord, asString, GITHUB_API_BASE, githubHeaders } from "./github";
|
|
6
|
-
import { copyIncludedPaths, findNearestIncludeConfig } from "./kit-include";
|
|
7
6
|
import { generateMetadataFlat, loadStashFile } from "./metadata";
|
|
8
7
|
import { parseRegistryIndex } from "./providers/static-index";
|
|
9
|
-
import {
|
|
8
|
+
import { copyIncludedPaths, findNearestIncludeConfig } from "./stash-include";
|
|
9
|
+
import { detectStashRoot } from "./stash-providers/provider-utils";
|
|
10
|
+
import { extractTarGzSecure } from "./stash-providers/tar-utils";
|
|
10
11
|
import { walkStashFlat } from "./walker";
|
|
11
12
|
const DEFAULT_NPM_REGISTRY_BASE = "https://registry.npmjs.org";
|
|
12
13
|
const DEFAULT_MANUAL_ENTRIES_PATH = path.resolve("manual-entries.json");
|
|
13
14
|
const DEFAULT_OUTPUT_PATH = path.resolve("index.json");
|
|
14
|
-
const REQUIRED_KEYWORDS = ["akm-
|
|
15
|
-
const GITHUB_TOPICS = ["akm-
|
|
15
|
+
const REQUIRED_KEYWORDS = ["akm-stash"];
|
|
16
|
+
const GITHUB_TOPICS = ["akm-stash"];
|
|
16
17
|
const EXCLUDED_REPOS = new Set(["itlackey/akm"]);
|
|
17
18
|
const EXCLUDED_NPM_PACKAGES = new Set(["akm-cli"]);
|
|
18
19
|
const EMPTY_INSPECTION = {};
|
|
@@ -25,11 +26,11 @@ export async function buildRegistryIndex(options) {
|
|
|
25
26
|
scanNpm(npmRegistryBase),
|
|
26
27
|
scanGithub(githubApiBase),
|
|
27
28
|
]);
|
|
28
|
-
const
|
|
29
|
+
const stashes = deduplicateStashes([...manualKits, ...npmKits, ...githubKits]).sort((a, b) => a.name.localeCompare(b.name));
|
|
29
30
|
const index = {
|
|
30
|
-
version:
|
|
31
|
+
version: 3,
|
|
31
32
|
updatedAt: new Date().toISOString(),
|
|
32
|
-
|
|
33
|
+
stashes,
|
|
33
34
|
};
|
|
34
35
|
return {
|
|
35
36
|
index,
|
|
@@ -37,7 +38,7 @@ export async function buildRegistryIndex(options) {
|
|
|
37
38
|
manual: manualKits.length,
|
|
38
39
|
npm: npmKits.length,
|
|
39
40
|
github: githubKits.length,
|
|
40
|
-
total:
|
|
41
|
+
total: stashes.length,
|
|
41
42
|
},
|
|
42
43
|
paths: {
|
|
43
44
|
manualEntriesPath,
|
|
@@ -51,7 +52,7 @@ export function writeRegistryIndex(index, outPath) {
|
|
|
51
52
|
return resolved;
|
|
52
53
|
}
|
|
53
54
|
async function scanNpm(npmRegistryBase) {
|
|
54
|
-
const
|
|
55
|
+
const stashes = [];
|
|
55
56
|
const seen = new Set();
|
|
56
57
|
for (const keyword of REQUIRED_KEYWORDS) {
|
|
57
58
|
let offset = 0;
|
|
@@ -83,7 +84,7 @@ async function scanNpm(npmRegistryBase) {
|
|
|
83
84
|
}
|
|
84
85
|
const inspection = await inspectNpmPackage(npmRegistryBase, latestMetadata).catch(() => EMPTY_INSPECTION);
|
|
85
86
|
const tags = mergeStrings((pkg.keywords ?? []).filter((value) => !REQUIRED_KEYWORDS.includes(value.toLowerCase())), inspection.tags);
|
|
86
|
-
|
|
87
|
+
stashes.push(normalizeStash({
|
|
87
88
|
id,
|
|
88
89
|
name: pkg.name,
|
|
89
90
|
description: inspection.description ?? pkg.description,
|
|
@@ -103,7 +104,7 @@ async function scanNpm(npmRegistryBase) {
|
|
|
103
104
|
offset += size;
|
|
104
105
|
}
|
|
105
106
|
}
|
|
106
|
-
return
|
|
107
|
+
return stashes;
|
|
107
108
|
}
|
|
108
109
|
async function inspectNpmPackage(_npmRegistryBase, latestMetadata) {
|
|
109
110
|
const dist = asRecord(latestMetadata.dist);
|
|
@@ -121,7 +122,7 @@ async function inspectNpmPackage(_npmRegistryBase, latestMetadata) {
|
|
|
121
122
|
};
|
|
122
123
|
}
|
|
123
124
|
async function scanGithub(githubApiBase) {
|
|
124
|
-
const
|
|
125
|
+
const stashes = [];
|
|
125
126
|
const seen = new Set();
|
|
126
127
|
const headers = githubHeaders();
|
|
127
128
|
for (const topic of GITHUB_TOPICS) {
|
|
@@ -140,7 +141,7 @@ async function scanGithub(githubApiBase) {
|
|
|
140
141
|
seen.add(id);
|
|
141
142
|
const inspection = await inspectArchive(`${githubApiBase}/repos/${repo.full_name}/tarball/${encodeURIComponent(repo.default_branch)}`, headers).catch(() => EMPTY_INSPECTION);
|
|
142
143
|
const topics = repo.topics.filter((value) => !GITHUB_TOPICS.includes(value));
|
|
143
|
-
|
|
144
|
+
stashes.push(normalizeStash({
|
|
144
145
|
id,
|
|
145
146
|
name: repo.name,
|
|
146
147
|
description: inspection.description ?? repo.description ?? undefined,
|
|
@@ -160,7 +161,7 @@ async function scanGithub(githubApiBase) {
|
|
|
160
161
|
page += 1;
|
|
161
162
|
}
|
|
162
163
|
}
|
|
163
|
-
return
|
|
164
|
+
return stashes;
|
|
164
165
|
}
|
|
165
166
|
async function inspectArchive(url, headers) {
|
|
166
167
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-registry-build-"));
|
|
@@ -269,36 +270,42 @@ function applyIncludeConfigForInspection(stashRoot, tempDir, searchRoot) {
|
|
|
269
270
|
async function loadManualEntries(manualEntriesPath) {
|
|
270
271
|
try {
|
|
271
272
|
const raw = JSON.parse(fs.readFileSync(manualEntriesPath, "utf8"));
|
|
272
|
-
const candidateKits = Array.isArray(raw) ? raw : asRecord(raw).
|
|
273
|
-
const parsed = parseRegistryIndex({ version:
|
|
273
|
+
const candidateKits = Array.isArray(raw) ? raw : asRecord(raw).stashes;
|
|
274
|
+
const parsed = parseRegistryIndex({ version: 3, updatedAt: new Date().toISOString(), stashes: candidateKits });
|
|
274
275
|
if (!parsed)
|
|
275
276
|
return [];
|
|
276
|
-
return parsed.
|
|
277
|
+
return parsed.stashes.map((stash) => normalizeStash({ ...stash, curated: stash.curated ?? true }));
|
|
277
278
|
}
|
|
278
279
|
catch {
|
|
279
280
|
return [];
|
|
280
281
|
}
|
|
281
282
|
}
|
|
283
|
+
// npm / GitHub API JSON pages; 25 MB cap covers the largest realistic
|
|
284
|
+
// search result set while still bounding memory against a malicious or
|
|
285
|
+
// misconfigured upstream that streams unbounded JSON.
|
|
286
|
+
const BUILD_INDEX_JSON_BYTE_CAP = 25 * 1024 * 1024;
|
|
282
287
|
async function fetchJson(url, headers) {
|
|
283
288
|
const response = await fetchWithRetry(url, headers ? { headers } : undefined, { timeout: 30_000 });
|
|
284
289
|
if (!response.ok) {
|
|
290
|
+
// Error-body sampling is intentionally small; 4 KB is plenty to
|
|
291
|
+
// include upstream hints in the thrown error.
|
|
285
292
|
const body = await response.text().catch(() => "");
|
|
286
293
|
throw new Error(`HTTP ${response.status} from ${url}: ${body.slice(0, 200)}`);
|
|
287
294
|
}
|
|
288
|
-
return (
|
|
295
|
+
return jsonWithByteCap(response, BUILD_INDEX_JSON_BYTE_CAP);
|
|
289
296
|
}
|
|
290
|
-
function
|
|
297
|
+
function deduplicateStashes(stashes) {
|
|
291
298
|
const byId = new Map();
|
|
292
|
-
for (const
|
|
293
|
-
const existing = byId.get(
|
|
294
|
-
byId.set(
|
|
299
|
+
for (const stash of stashes) {
|
|
300
|
+
const existing = byId.get(stash.id);
|
|
301
|
+
byId.set(stash.id, existing ? mergeEntries(existing, stash) : stash);
|
|
295
302
|
}
|
|
296
303
|
return [...byId.values()];
|
|
297
304
|
}
|
|
298
305
|
function mergeEntries(a, b) {
|
|
299
306
|
const assets = mergeAssets(a.assets, b.assets);
|
|
300
307
|
const assetTypes = mergeStrings(a.assetTypes, b.assetTypes, assets ? deriveAssetTypes(assets) : undefined);
|
|
301
|
-
return
|
|
308
|
+
return normalizeStash({
|
|
302
309
|
id: a.id,
|
|
303
310
|
name: a.name,
|
|
304
311
|
description: a.description ?? b.description,
|
|
@@ -343,12 +350,12 @@ function extractNonReservedKeywords(value) {
|
|
|
343
350
|
.filter((item) => !REQUIRED_KEYWORDS.includes(item.toLowerCase()));
|
|
344
351
|
return filtered.length > 0 ? filtered : undefined;
|
|
345
352
|
}
|
|
346
|
-
function
|
|
347
|
-
const assets =
|
|
353
|
+
function normalizeStash(stash) {
|
|
354
|
+
const assets = stash.assets ? sortAssets(stash.assets) : undefined;
|
|
348
355
|
return {
|
|
349
|
-
...
|
|
350
|
-
...(
|
|
351
|
-
...(
|
|
356
|
+
...stash,
|
|
357
|
+
...(stash.tags && stash.tags.length > 0 ? { tags: stash.tags } : {}),
|
|
358
|
+
...(stash.assetTypes && stash.assetTypes.length > 0 ? { assetTypes: stash.assetTypes } : {}),
|
|
352
359
|
...(assets && assets.length > 0 ? { assets } : {}),
|
|
353
360
|
};
|
|
354
361
|
}
|
package/dist/registry-factory.js
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Maps registry provider type identifiers (e.g. "static-index", "skills-sh")
|
|
5
5
|
* to factory functions that create RegistryProvider instances.
|
|
6
6
|
*
|
|
7
|
-
* "Registry" here refers to the
|
|
7
|
+
* "Registry" here refers to the stash discovery registries (npm, GitHub, static
|
|
8
8
|
* index files) — not to be confused with the stash provider factory map in
|
|
9
|
-
* stash-provider-factory.ts or the installed-
|
|
9
|
+
* stash-provider-factory.ts or the installed-stash operations in installed-stashes.ts.
|
|
10
10
|
*/
|
|
11
11
|
import { createProviderRegistry } from "./create-provider-registry";
|
|
12
12
|
// ── Factory map ─────────────────────────────────────────────────────────────
|
package/dist/registry-resolve.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
-
import { fetchWithRetry } from "./common";
|
|
6
|
+
import { fetchWithRetry, jsonWithByteCap } from "./common";
|
|
7
7
|
import { UsageError } from "./errors";
|
|
8
8
|
import { asRecord, asString, GITHUB_API_BASE, githubHeaders } from "./github";
|
|
9
9
|
/**
|
|
@@ -451,7 +451,7 @@ function fileUriToPath(ref) {
|
|
|
451
451
|
/**
|
|
452
452
|
* Build a human-readable local ID from an absolute path.
|
|
453
453
|
* /home/user/akm/skills → ~/akm/skills
|
|
454
|
-
* /tmp/my-
|
|
454
|
+
* /tmp/my-stash → /tmp/my-stash
|
|
455
455
|
*/
|
|
456
456
|
function toReadableLocalId(absolutePath) {
|
|
457
457
|
const home = os.homedir();
|
|
@@ -571,16 +571,20 @@ export function maxSatisfying(versions, range) {
|
|
|
571
571
|
candidates.sort((a, b) => compareSemver(b.parsed, a.parsed));
|
|
572
572
|
return candidates[0].version;
|
|
573
573
|
}
|
|
574
|
+
// Cap JSON responses at 10 MB — npm package manifests and GitHub API
|
|
575
|
+
// responses are typically a few KB; a compromised registry streaming
|
|
576
|
+
// tens of MB of JSON is a DoS surface, not a feature.
|
|
577
|
+
const REGISTRY_JSON_BYTE_CAP = 10 * 1024 * 1024;
|
|
574
578
|
async function fetchJson(url, headers) {
|
|
575
579
|
const response = await fetchWithRetry(url, { headers });
|
|
576
580
|
if (!response.ok) {
|
|
577
581
|
throw new Error(`Request failed (${response.status}) for ${url}`);
|
|
578
582
|
}
|
|
579
|
-
return (
|
|
583
|
+
return jsonWithByteCap(response, REGISTRY_JSON_BYTE_CAP);
|
|
580
584
|
}
|
|
581
585
|
async function tryFetchJson(url, headers) {
|
|
582
586
|
const response = await fetchWithRetry(url, { headers });
|
|
583
587
|
if (!response.ok)
|
|
584
588
|
return null;
|
|
585
|
-
return (
|
|
589
|
+
return jsonWithByteCap(response, REGISTRY_JSON_BYTE_CAP);
|
|
586
590
|
}
|
package/dist/registry-search.js
CHANGED
|
@@ -2,8 +2,7 @@ import { toErrorMessage } from "./common";
|
|
|
2
2
|
import { DEFAULT_CONFIG, loadConfig } from "./config";
|
|
3
3
|
import { resolveProviderFactory } from "./registry-factory";
|
|
4
4
|
// ── Eagerly import providers to trigger self-registration ───────────────────
|
|
5
|
-
import "./providers/
|
|
6
|
-
import "./providers/skills-sh";
|
|
5
|
+
import "./providers/index";
|
|
7
6
|
// ── Public API ──────────────────────────────────────────────────────────────
|
|
8
7
|
export async function searchRegistry(query, options) {
|
|
9
8
|
const trimmed = query.trim();
|
|
@@ -33,9 +32,28 @@ export async function searchRegistry(query, options) {
|
|
|
33
32
|
const value = result.value;
|
|
34
33
|
if (!value)
|
|
35
34
|
continue;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
let dropped = 0;
|
|
36
|
+
for (const hit of value.hits) {
|
|
37
|
+
if (isCompleteHit(hit)) {
|
|
38
|
+
allHits.push(hit);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
dropped++;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (value.assetHits) {
|
|
45
|
+
for (const hit of value.assetHits) {
|
|
46
|
+
if (isCompleteAssetHit(hit)) {
|
|
47
|
+
allAssetHits.push(hit);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
dropped++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (dropped > 0) {
|
|
55
|
+
warnings.push(`Registry returned ${dropped} incomplete hit(s); dropped from response.`);
|
|
56
|
+
}
|
|
39
57
|
if (value.warnings)
|
|
40
58
|
warnings.push(...value.warnings);
|
|
41
59
|
}
|
|
@@ -97,3 +115,42 @@ function clampLimit(limit) {
|
|
|
97
115
|
return 20;
|
|
98
116
|
return Math.min(100, Math.max(1, Math.trunc(limit)));
|
|
99
117
|
}
|
|
118
|
+
// A complete hit must have the fields downstream consumers (CLI rendering,
|
|
119
|
+
// `akm add`) rely on. Providers that return partial records would otherwise
|
|
120
|
+
// surface as `{}` in the JSON output.
|
|
121
|
+
function isCompleteHit(hit) {
|
|
122
|
+
if (!hit || typeof hit !== "object")
|
|
123
|
+
return false;
|
|
124
|
+
return (typeof hit.source === "string" &&
|
|
125
|
+
typeof hit.id === "string" &&
|
|
126
|
+
hit.id.length > 0 &&
|
|
127
|
+
typeof hit.title === "string" &&
|
|
128
|
+
hit.title.length > 0 &&
|
|
129
|
+
typeof hit.ref === "string" &&
|
|
130
|
+
hit.ref.length > 0 &&
|
|
131
|
+
typeof hit.installRef === "string" &&
|
|
132
|
+
hit.installRef.length > 0);
|
|
133
|
+
}
|
|
134
|
+
function isCompleteAssetHit(hit) {
|
|
135
|
+
if (!hit || typeof hit !== "object")
|
|
136
|
+
return false;
|
|
137
|
+
if (hit.type !== "registry-asset" ||
|
|
138
|
+
typeof hit.assetType !== "string" ||
|
|
139
|
+
hit.assetType.length === 0 ||
|
|
140
|
+
typeof hit.assetName !== "string" ||
|
|
141
|
+
hit.assetName.length === 0 ||
|
|
142
|
+
typeof hit.action !== "string") {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
// `stash` is required by the consumer (output shaping + asset-action display);
|
|
146
|
+
// rejecting incomplete stashes here keeps malformed objects out of the JSON
|
|
147
|
+
// output. Flagged in PR #168 review (#9).
|
|
148
|
+
const stash = hit.stash;
|
|
149
|
+
if (!stash || typeof stash !== "object")
|
|
150
|
+
return false;
|
|
151
|
+
if (typeof stash.id !== "string" || stash.id.length === 0)
|
|
152
|
+
return false;
|
|
153
|
+
if (typeof stash.name !== "string" || stash.name.length === 0)
|
|
154
|
+
return false;
|
|
155
|
+
return true;
|
|
156
|
+
}
|
package/dist/remember.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
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 { tryReadStdinText } from "./common";
|
|
10
|
+
import { loadConfig } from "./config";
|
|
11
|
+
import { UsageError } from "./errors";
|
|
12
|
+
import { warn } from "./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.`);
|
|
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 && 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 && fields.source.trim())
|
|
48
|
+
obj.source = fields.source;
|
|
49
|
+
if (fields.observed_at && fields.observed_at.trim())
|
|
50
|
+
obj.observed_at = fields.observed_at;
|
|
51
|
+
if (fields.expires && 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
|
+
let observed_at;
|
|
90
|
+
const isoMatch = body.match(/\b(\d{4}-\d{2}-\d{2})\b/);
|
|
91
|
+
if (isoMatch) {
|
|
92
|
+
observed_at = isoMatch[1];
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const relMatch = body.match(/\b(today|yesterday|last\s+week|last\s+month)\b/i);
|
|
96
|
+
if (relMatch) {
|
|
97
|
+
const phrase = relMatch[1].toLowerCase();
|
|
98
|
+
const now = new Date();
|
|
99
|
+
if (phrase === "today") {
|
|
100
|
+
observed_at = now.toISOString().slice(0, 10);
|
|
101
|
+
}
|
|
102
|
+
else if (phrase === "yesterday") {
|
|
103
|
+
const d = new Date(now);
|
|
104
|
+
d.setDate(d.getDate() - 1);
|
|
105
|
+
observed_at = d.toISOString().slice(0, 10);
|
|
106
|
+
}
|
|
107
|
+
else if (phrase.startsWith("last week")) {
|
|
108
|
+
const d = new Date(now);
|
|
109
|
+
d.setDate(d.getDate() - 7);
|
|
110
|
+
observed_at = d.toISOString().slice(0, 10);
|
|
111
|
+
}
|
|
112
|
+
else if (phrase.startsWith("last month")) {
|
|
113
|
+
const d = new Date(now);
|
|
114
|
+
d.setMonth(d.getMonth() - 1);
|
|
115
|
+
observed_at = d.toISOString().slice(0, 10);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { tags, source, observed_at, subjective };
|
|
120
|
+
}
|
|
121
|
+
/** Hard timeout for the `--enrich` LLM call. Write-path must not block on a misbehaving endpoint. */
|
|
122
|
+
const LLM_ENRICH_TIMEOUT_MS = 10_000;
|
|
123
|
+
/**
|
|
124
|
+
* Attempt LLM enrichment of memory metadata. Returns merged metadata
|
|
125
|
+
* fields on success. On timeout, unreachable, or invalid JSON — returns
|
|
126
|
+
* empty result and emits a warning. Never throws; always resolves.
|
|
127
|
+
*/
|
|
128
|
+
export async function runLlmEnrich(body) {
|
|
129
|
+
const config = loadConfig();
|
|
130
|
+
if (!config.llm) {
|
|
131
|
+
warn("Warning: --enrich requires an LLM to be configured. Run `akm config set llm` to configure one.");
|
|
132
|
+
return { tags: [] };
|
|
133
|
+
}
|
|
134
|
+
const { chatCompletion, parseJsonResponse } = await import("./llm.js");
|
|
135
|
+
const prompt = `You are a memory tagger for a developer knowledge base.
|
|
136
|
+
Given the memory text below, return ONLY a JSON object with these fields:
|
|
137
|
+
- "tags": array of 1-5 short lowercase keyword tags
|
|
138
|
+
- "description": one-sentence summary (optional)
|
|
139
|
+
- "observed_at": ISO date (YYYY-MM-DD) if the text references a specific date (optional)
|
|
140
|
+
|
|
141
|
+
Memory text:
|
|
142
|
+
${body.slice(0, 2000)}
|
|
143
|
+
|
|
144
|
+
Return ONLY the JSON object, no prose, no markdown fences.`;
|
|
145
|
+
try {
|
|
146
|
+
const result = await Promise.race([
|
|
147
|
+
chatCompletion(config.llm, [
|
|
148
|
+
{ role: "system", content: "Return only valid JSON. No prose." },
|
|
149
|
+
{ role: "user", content: prompt },
|
|
150
|
+
], { maxTokens: 256, temperature: 0.1 }),
|
|
151
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("LLM enrichment timed out")), LLM_ENRICH_TIMEOUT_MS)),
|
|
152
|
+
]);
|
|
153
|
+
const parsed = parseJsonResponse(result);
|
|
154
|
+
if (!parsed) {
|
|
155
|
+
warn("Warning: --enrich received invalid JSON from the LLM. Writing memory without enrichment.");
|
|
156
|
+
return { tags: [] };
|
|
157
|
+
}
|
|
158
|
+
const tags = Array.isArray(parsed.tags)
|
|
159
|
+
? parsed.tags.filter((t) => typeof t === "string" && t.trim().length > 0)
|
|
160
|
+
: [];
|
|
161
|
+
const description = typeof parsed.description === "string" && parsed.description.trim() ? parsed.description.trim() : undefined;
|
|
162
|
+
const observed_at = typeof parsed.observed_at === "string" && /^\d{4}-\d{2}-\d{2}$/.test(parsed.observed_at.trim())
|
|
163
|
+
? parsed.observed_at.trim()
|
|
164
|
+
: undefined;
|
|
165
|
+
return { tags, description, observed_at };
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
169
|
+
warn(`Warning: --enrich failed (${msg}). Writing memory without enrichment.`);
|
|
170
|
+
return { tags: [] };
|
|
171
|
+
}
|
|
172
|
+
}
|