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.
Files changed (74) hide show
  1. package/CHANGELOG.md +32 -5
  2. package/dist/asset-registry.js +29 -5
  3. package/dist/asset-spec.js +12 -5
  4. package/dist/cli-hints.js +300 -0
  5. package/dist/cli.js +218 -1357
  6. package/dist/common.js +147 -50
  7. package/dist/config.js +224 -13
  8. package/dist/create-provider-registry.js +1 -1
  9. package/dist/curate.js +258 -0
  10. package/dist/{local-search.js → db-search.js} +30 -19
  11. package/dist/db.js +168 -62
  12. package/dist/embedder.js +49 -273
  13. package/dist/embedders/cache.js +47 -0
  14. package/dist/embedders/local.js +152 -0
  15. package/dist/embedders/remote.js +121 -0
  16. package/dist/embedders/types.js +39 -0
  17. package/dist/errors.js +14 -3
  18. package/dist/frontmatter.js +61 -7
  19. package/dist/indexer.js +38 -7
  20. package/dist/info.js +2 -2
  21. package/dist/install-audit.js +16 -1
  22. package/dist/{installed-kits.js → installed-stashes.js} +48 -22
  23. package/dist/llm-client.js +92 -0
  24. package/dist/llm.js +14 -126
  25. package/dist/lockfile.js +28 -1
  26. package/dist/matchers.js +1 -1
  27. package/dist/metadata-enhance.js +53 -0
  28. package/dist/migration-help.js +75 -44
  29. package/dist/output-context.js +77 -0
  30. package/dist/output-shapes.js +198 -0
  31. package/dist/output-text.js +520 -0
  32. package/dist/paths.js +4 -4
  33. package/dist/providers/index.js +11 -0
  34. package/dist/providers/skills-sh.js +1 -1
  35. package/dist/providers/static-index.js +47 -45
  36. package/dist/registry-build-index.js +36 -29
  37. package/dist/registry-factory.js +2 -2
  38. package/dist/registry-resolve.js +8 -4
  39. package/dist/registry-search.js +62 -5
  40. package/dist/remember.js +172 -0
  41. package/dist/renderers.js +52 -0
  42. package/dist/search-source.js +73 -42
  43. package/dist/setup-steps.js +45 -0
  44. package/dist/setup.js +149 -76
  45. package/dist/stash-add.js +94 -38
  46. package/dist/stash-clone.js +4 -4
  47. package/dist/stash-provider-factory.js +2 -2
  48. package/dist/stash-provider.js +3 -1
  49. package/dist/stash-providers/filesystem.js +31 -1
  50. package/dist/stash-providers/git.js +209 -8
  51. package/dist/stash-providers/index.js +1 -0
  52. package/dist/stash-providers/npm.js +159 -0
  53. package/dist/stash-providers/provider-utils.js +162 -0
  54. package/dist/stash-providers/sync-from-ref.js +45 -0
  55. package/dist/stash-providers/tar-utils.js +151 -0
  56. package/dist/stash-providers/website.js +80 -4
  57. package/dist/stash-resolve.js +5 -5
  58. package/dist/stash-search.js +4 -4
  59. package/dist/stash-show.js +3 -3
  60. package/dist/wiki.js +6 -6
  61. package/dist/workflow-authoring.js +12 -4
  62. package/dist/workflow-markdown.js +9 -0
  63. package/dist/workflow-runs.js +12 -2
  64. package/docs/README.md +30 -0
  65. package/docs/migration/release-notes/0.0.13.md +4 -0
  66. package/docs/migration/release-notes/0.1.0.md +6 -0
  67. package/docs/migration/release-notes/0.2.0.md +6 -0
  68. package/docs/migration/release-notes/0.3.0.md +5 -0
  69. package/docs/migration/release-notes/0.5.0.md +6 -0
  70. package/docs/migration/release-notes/0.6.0.md +29 -0
  71. package/docs/migration/release-notes/README.md +21 -0
  72. package/package.json +3 -2
  73. package/dist/registry-install.js +0 -532
  74. /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 kit of index.kits) {
27
- allKits.push({ kit, registryName: regName });
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
- const data = (await response.json());
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" || (obj.version !== 1 && obj.version !== 2))
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.kits))
129
+ if (!Array.isArray(obj.stashes))
128
130
  return null;
129
- const kits = obj.kits.flatMap((raw) => {
130
- const kit = parseKitEntry(raw);
131
- return kit ? [kit] : [];
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, kits };
135
+ return { version: obj.version, updatedAt: obj.updatedAt, stashes };
134
136
  }
135
- // ── Kit entry parsing ───────────────────────────────────────────────────────
136
- function parseKitEntry(raw) {
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(kits, query, limit) {
165
+ function scoreKits(stashes, query, limit) {
164
166
  const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
165
167
  const scored = [];
166
- for (const { kit, registryName } of kits) {
167
- const score = scoreKit(kit, tokens);
168
+ for (const { stash, registryName } of stashes) {
169
+ const score = scoreStash(stash, tokens);
168
170
  if (score > 0) {
169
- scored.push({ kit, registryName, score });
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(({ kit, registryName, score }) => toSearchHit(kit, score, registryName));
175
+ return scored.slice(0, limit).map(({ stash, registryName, score }) => toSearchHit(stash, score, registryName));
174
176
  }
175
- function scoreKit(kit, tokens) {
177
+ function scoreStash(stash, tokens) {
176
178
  let score = 0;
177
- const nameLower = kit.name.toLowerCase();
178
- const descLower = (kit.description ?? "").toLowerCase();
179
- const tagsLower = (kit.tags ?? []).map((t) => t.toLowerCase());
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 (kit.author?.toLowerCase().includes(token)) {
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(kit, score, registryName) {
209
+ function toSearchHit(stash, score, registryName) {
208
210
  const metadata = {};
209
- if (kit.latestVersion)
210
- metadata.version = kit.latestVersion;
211
- if (kit.author)
212
- metadata.author = kit.author;
213
- if (kit.license)
214
- metadata.license = kit.license;
215
- if (kit.assetTypes?.length)
216
- metadata.assetTypes = kit.assetTypes.join(", ");
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: kit.source,
219
- id: kit.id,
220
- title: kit.name,
221
- description: kit.description,
222
- ref: kit.ref,
223
- installRef: buildInstallRef(kit.source, kit.ref),
224
- homepage: kit.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: kit.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(kits, query, limit) {
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 { kit, registryName } of kits) {
264
- if (!kit.assets || kit.assets.length === 0)
265
+ for (const { stash, registryName } of stashes) {
266
+ if (!stash.assets || stash.assets.length === 0)
265
267
  continue;
266
- const installRef = buildInstallRef(kit.source, kit.ref);
267
- for (const asset of kit.assets) {
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
- kit: { id: kit.id, name: kit.name },
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 { detectStashRoot, extractTarGzSecure } from "./registry-install";
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-kit"];
15
- const GITHUB_TOPICS = ["akm-kit"];
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 kits = deduplicateKits([...manualKits, ...npmKits, ...githubKits]).sort((a, b) => a.name.localeCompare(b.name));
29
+ const stashes = deduplicateStashes([...manualKits, ...npmKits, ...githubKits]).sort((a, b) => a.name.localeCompare(b.name));
29
30
  const index = {
30
- version: 2,
31
+ version: 3,
31
32
  updatedAt: new Date().toISOString(),
32
- kits,
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: kits.length,
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 kits = [];
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
- kits.push(normalizeKit({
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 kits;
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 kits = [];
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
- kits.push(normalizeKit({
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 kits;
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).kits;
273
- const parsed = parseRegistryIndex({ version: 2, updatedAt: new Date().toISOString(), kits: candidateKits });
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.kits.map((kit) => normalizeKit({ ...kit, curated: kit.curated ?? true }));
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 (await response.json());
295
+ return jsonWithByteCap(response, BUILD_INDEX_JSON_BYTE_CAP);
289
296
  }
290
- function deduplicateKits(kits) {
297
+ function deduplicateStashes(stashes) {
291
298
  const byId = new Map();
292
- for (const kit of kits) {
293
- const existing = byId.get(kit.id);
294
- byId.set(kit.id, existing ? mergeEntries(existing, kit) : kit);
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 normalizeKit({
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 normalizeKit(kit) {
347
- const assets = kit.assets ? sortAssets(kit.assets) : undefined;
353
+ function normalizeStash(stash) {
354
+ const assets = stash.assets ? sortAssets(stash.assets) : undefined;
348
355
  return {
349
- ...kit,
350
- ...(kit.tags && kit.tags.length > 0 ? { tags: kit.tags } : {}),
351
- ...(kit.assetTypes && kit.assetTypes.length > 0 ? { assetTypes: kit.assetTypes } : {}),
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
  }
@@ -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 kit discovery registries (npm, GitHub, static
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-kit operations in installed-kits.ts.
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 ─────────────────────────────────────────────────────────────
@@ -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-kit → /tmp/my-kit
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 (await response.json());
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 (await response.json());
589
+ return jsonWithByteCap(response, REGISTRY_JSON_BYTE_CAP);
586
590
  }
@@ -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/static-index";
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
- allHits.push(...value.hits);
37
- if (value.assetHits)
38
- allAssetHits.push(...value.assetHits);
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
+ }
@@ -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
+ }