akm-cli 0.1.2 → 0.2.0
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/README.md +4 -1
- package/dist/asset-registry.js +48 -0
- package/dist/asset-spec.js +11 -32
- package/dist/cli.js +174 -57
- package/dist/completions.js +4 -2
- package/dist/config.js +34 -6
- package/dist/db.js +178 -22
- package/dist/detect.js +120 -0
- package/dist/embedder.js +94 -13
- package/dist/file-context.js +3 -0
- package/dist/indexer.js +88 -37
- package/dist/info.js +92 -0
- package/dist/local-search.js +190 -90
- package/dist/manifest.js +172 -0
- package/dist/metadata.js +165 -2
- package/dist/providers/skills-sh.js +21 -12
- package/dist/providers/static-index.js +3 -1
- package/dist/registry-build-index.js +12 -1
- package/dist/registry-resolve.js +10 -7
- package/dist/search-fields.js +69 -0
- package/dist/search-source.js +42 -0
- package/dist/setup.js +506 -0
- package/dist/stash-clone.js +3 -1
- package/dist/stash-provider-factory.js +0 -2
- package/dist/stash-providers/filesystem.js +4 -5
- package/dist/stash-providers/git.js +140 -0
- package/dist/stash-providers/index.js +1 -1
- package/dist/stash-providers/openviking.js +36 -25
- package/dist/stash-providers/provider-utils.js +11 -0
- package/dist/stash-search.js +106 -90
- package/dist/stash-show.js +125 -9
- package/dist/usage-events.js +73 -0
- package/dist/version.js +20 -0
- package/dist/walker.js +1 -2
- package/package.json +4 -2
- package/dist/stash-providers/context-hub.js +0 -389
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fetchWithRetry } from "../common";
|
|
5
|
+
import { ConfigError } from "../errors";
|
|
6
|
+
import { getRegistryIndexCacheDir } from "../paths";
|
|
7
|
+
import { extractTarGzSecure } from "../registry-install";
|
|
8
|
+
import { registerStashProvider } from "../stash-provider-factory";
|
|
9
|
+
import { isExpired, sanitizeString } from "./provider-utils";
|
|
10
|
+
/** Cache TTL before refreshing the mirrored repo (12 hours). */
|
|
11
|
+
const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
12
|
+
/** Maximum stale age allowed when refresh fails (7 days). */
|
|
13
|
+
const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
14
|
+
class GitStashProvider {
|
|
15
|
+
type = "git";
|
|
16
|
+
name;
|
|
17
|
+
constructor(config) {
|
|
18
|
+
this.name = config.name ?? "git";
|
|
19
|
+
}
|
|
20
|
+
/** Content is indexed through the standard FTS5 pipeline. */
|
|
21
|
+
async search(_options) {
|
|
22
|
+
return { hits: [] };
|
|
23
|
+
}
|
|
24
|
+
/** Content is local files, shown via showLocal. */
|
|
25
|
+
async show(_ref, _view) {
|
|
26
|
+
throw new Error("Git provider content is shown via local index");
|
|
27
|
+
}
|
|
28
|
+
/** Content is local; no remote show needed. */
|
|
29
|
+
canShow(_ref) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// ── Self-register ───────────────────────────────────────────────────────────
|
|
34
|
+
registerStashProvider("git", (config) => new GitStashProvider(config));
|
|
35
|
+
registerStashProvider("context-hub", (config) => new GitStashProvider(config));
|
|
36
|
+
registerStashProvider("github", (config) => new GitStashProvider(config));
|
|
37
|
+
// ── Cache management ────────────────────────────────────────────────────────
|
|
38
|
+
function getCachePaths(repoUrl) {
|
|
39
|
+
const key = createHash("sha256").update(repoUrl).digest("hex").slice(0, 16);
|
|
40
|
+
const rootDir = path.join(getRegistryIndexCacheDir(), `context-hub-${key}`);
|
|
41
|
+
return {
|
|
42
|
+
rootDir,
|
|
43
|
+
archivePath: path.join(rootDir, "repo.tar.gz"),
|
|
44
|
+
repoDir: path.join(rootDir, "repo"),
|
|
45
|
+
indexPath: path.join(rootDir, "index.json"),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async function ensureGitMirror(repo, cachePaths, options) {
|
|
49
|
+
const requireRepoDir = options?.requireRepoDir === true;
|
|
50
|
+
// Check if cache is fresh
|
|
51
|
+
let mtime = 0;
|
|
52
|
+
try {
|
|
53
|
+
mtime = fs.statSync(cachePaths.indexPath).mtimeMs;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* no cached index */
|
|
57
|
+
}
|
|
58
|
+
if (mtime && !isExpired(mtime, CACHE_TTL_MS) && (!requireRepoDir || hasExtractedRepo(cachePaths.repoDir))) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
fs.mkdirSync(cachePaths.rootDir, { recursive: true });
|
|
63
|
+
await downloadArchive(buildTarballUrl(repo), cachePaths.archivePath);
|
|
64
|
+
extractTarGzSecure(cachePaths.archivePath, cachePaths.repoDir);
|
|
65
|
+
// Touch index file to track freshness
|
|
66
|
+
fs.writeFileSync(cachePaths.indexPath, "[]", { encoding: "utf8", mode: 0o600 });
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
if (mtime && !isExpired(mtime, CACHE_STALE_MS) && (!requireRepoDir || hasExtractedRepo(cachePaths.repoDir))) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function hasExtractedRepo(repoDir) {
|
|
76
|
+
try {
|
|
77
|
+
return fs.statSync(repoDir).isDirectory() && fs.statSync(path.join(repoDir, "content")).isDirectory();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function downloadArchive(url, destination) {
|
|
84
|
+
const response = await fetchWithRetry(url, undefined, { timeout: 120_000, retries: 1 });
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw new Error(`Failed to download archive (${response.status}) from ${url}`);
|
|
87
|
+
}
|
|
88
|
+
const BunRuntime = globalThis.Bun;
|
|
89
|
+
if (BunRuntime?.write) {
|
|
90
|
+
await BunRuntime.write(destination, response);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
94
|
+
fs.writeFileSync(destination, Buffer.from(arrayBuffer));
|
|
95
|
+
}
|
|
96
|
+
function buildTarballUrl(repo) {
|
|
97
|
+
return `https://github.com/${repo.owner}/${repo.repo}/archive/refs/heads/${repo.ref}.tar.gz`;
|
|
98
|
+
}
|
|
99
|
+
function parseGitRepoUrl(rawUrl) {
|
|
100
|
+
if (!rawUrl) {
|
|
101
|
+
throw new ConfigError("Git provider requires a GitHub repository URL");
|
|
102
|
+
}
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = new URL(rawUrl);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
throw new ConfigError(`Git provider URL is not valid: "${rawUrl}"`);
|
|
109
|
+
}
|
|
110
|
+
if (parsed.protocol !== "https:") {
|
|
111
|
+
throw new ConfigError(`Git provider URL must use https://, got "${parsed.protocol}"`);
|
|
112
|
+
}
|
|
113
|
+
if (parsed.hostname !== "github.com") {
|
|
114
|
+
throw new ConfigError(`Git provider only supports github.com URLs, got "${parsed.hostname}"`);
|
|
115
|
+
}
|
|
116
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
117
|
+
if (segments.length < 2) {
|
|
118
|
+
throw new ConfigError(`Git provider URL must point to a GitHub repository, got "${rawUrl}"`);
|
|
119
|
+
}
|
|
120
|
+
const owner = sanitizeString(segments[0]);
|
|
121
|
+
const repo = sanitizeString(segments[1].replace(/\.git$/i, ""));
|
|
122
|
+
let ref = "main";
|
|
123
|
+
if (segments[2] === "tree" && segments.length >= 4) {
|
|
124
|
+
ref = sanitizeString(segments.slice(3).join("/"), 255) || "main";
|
|
125
|
+
}
|
|
126
|
+
if (!owner || !repo || !/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) {
|
|
127
|
+
throw new ConfigError(`Unsupported repository URL: "${rawUrl}"`);
|
|
128
|
+
}
|
|
129
|
+
if (!ref || ref.includes("..") || !/^[A-Za-z0-9._/-]+$/.test(ref)) {
|
|
130
|
+
throw new ConfigError(`Unsupported branch/ref in URL: "${rawUrl}"`);
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
owner,
|
|
134
|
+
repo,
|
|
135
|
+
ref,
|
|
136
|
+
canonicalUrl: `https://github.com/${owner}/${repo}/tree/${ref}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// ── Exports ─────────────────────────────────────────────────────────────────
|
|
140
|
+
export { GitStashProvider, ensureGitMirror, getCachePaths, parseGitRepoUrl };
|
|
@@ -1,16 +1,11 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { fetchWithRetry } from "../common";
|
|
4
5
|
import { ConfigError, NotFoundError } from "../errors";
|
|
5
6
|
import { getRegistryIndexCacheDir } from "../paths";
|
|
6
7
|
import { registerStashProvider } from "../stash-provider-factory";
|
|
7
|
-
|
|
8
|
-
function sanitizeString(value, maxLength = 255) {
|
|
9
|
-
if (typeof value !== "string")
|
|
10
|
-
return "";
|
|
11
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — strip control chars from untrusted remote data
|
|
12
|
-
return value.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, maxLength);
|
|
13
|
-
}
|
|
8
|
+
import { isExpired, sanitizeString } from "./provider-utils";
|
|
14
9
|
/** Per-query cache TTL in milliseconds (5 minutes). */
|
|
15
10
|
const QUERY_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
16
11
|
/** Maximum age before query cache is considered stale but still usable (1 hour). */
|
|
@@ -70,7 +65,9 @@ class OpenVikingStashProvider {
|
|
|
70
65
|
}
|
|
71
66
|
}
|
|
72
67
|
async show(ref, _view) {
|
|
73
|
-
const
|
|
68
|
+
const trimmed = ref.trim();
|
|
69
|
+
// Accept both viking:// URIs (legacy/internal) and type:name refs
|
|
70
|
+
const uri = trimmed.startsWith("viking://") ? trimmed : refToVikingUri(trimmed);
|
|
74
71
|
const baseUrl = this.baseUrl;
|
|
75
72
|
const headers = this.authHeaders;
|
|
76
73
|
const [statResult, contentResult] = await Promise.all([
|
|
@@ -78,10 +75,10 @@ class OpenVikingStashProvider {
|
|
|
78
75
|
fetchOVJson(`${baseUrl}/api/v1/content/read?uri=${encodeURIComponent(uri)}&offset=0&limit=-1`, headers),
|
|
79
76
|
]);
|
|
80
77
|
if (statResult == null && contentResult == null) {
|
|
81
|
-
throw new NotFoundError(`Could not fetch remote asset "${
|
|
78
|
+
throw new NotFoundError(`Could not fetch remote asset "${trimmed}". The OpenViking server at ${baseUrl} may be unreachable or the resource does not exist.`);
|
|
82
79
|
}
|
|
83
80
|
if (contentResult == null) {
|
|
84
|
-
throw new NotFoundError(`Content not found for remote asset "${
|
|
81
|
+
throw new NotFoundError(`Content not found for remote asset "${trimmed}". The server returned metadata but no content.`);
|
|
85
82
|
}
|
|
86
83
|
const stat = (typeof statResult === "object" && statResult !== null ? statResult : {});
|
|
87
84
|
const uriPath = uri.replace(/^viking:\/\//, "");
|
|
@@ -91,19 +88,20 @@ class OpenVikingStashProvider {
|
|
|
91
88
|
const assetType = OV_TYPE_MAP[ovType] ?? "knowledge";
|
|
92
89
|
const content = typeof contentResult === "string" ? contentResult : "";
|
|
93
90
|
const description = sanitizeString(stat.abstract, 1000) || undefined;
|
|
91
|
+
const assetRef = `${assetType}:${name}`;
|
|
94
92
|
return {
|
|
95
93
|
type: assetType,
|
|
96
94
|
name,
|
|
97
|
-
path:
|
|
98
|
-
action: `Remote content from OpenViking — ${
|
|
95
|
+
path: assetRef,
|
|
96
|
+
action: `Remote content from OpenViking — ${assetRef}`,
|
|
99
97
|
content,
|
|
100
98
|
description,
|
|
101
99
|
editable: false,
|
|
102
100
|
origin: "remote",
|
|
103
101
|
};
|
|
104
102
|
}
|
|
105
|
-
canShow(
|
|
106
|
-
return
|
|
103
|
+
canShow(_ref) {
|
|
104
|
+
return !!(this.config.url ?? "").trim();
|
|
107
105
|
}
|
|
108
106
|
get baseUrl() {
|
|
109
107
|
return (this.config.url ?? "").replace(/\/+$/, "");
|
|
@@ -162,9 +160,8 @@ class OpenVikingStashProvider {
|
|
|
162
160
|
const name = sanitizeString(entry.name);
|
|
163
161
|
const abstract = sanitizeString(entry.abstract, 1000);
|
|
164
162
|
const type = sanitizeString(entry.type);
|
|
165
|
-
const uri = sanitizeString(entry.uri, 2048);
|
|
166
163
|
const assetType = OV_TYPE_MAP[type] ?? "knowledge";
|
|
167
|
-
const ref =
|
|
164
|
+
const ref = `${assetType}:${name}`;
|
|
168
165
|
return {
|
|
169
166
|
type: assetType,
|
|
170
167
|
name,
|
|
@@ -180,7 +177,7 @@ class OpenVikingStashProvider {
|
|
|
180
177
|
}
|
|
181
178
|
queryCachePath(query, limit) {
|
|
182
179
|
const cacheDir = getRegistryIndexCacheDir();
|
|
183
|
-
const hasher =
|
|
180
|
+
const hasher = createHash("md5");
|
|
184
181
|
hasher.update(this.config.url ?? "");
|
|
185
182
|
hasher.update("\0");
|
|
186
183
|
hasher.update(query.trim().toLowerCase());
|
|
@@ -225,11 +222,28 @@ class OpenVikingStashProvider {
|
|
|
225
222
|
// ── Self-register ───────────────────────────────────────────────────────────
|
|
226
223
|
registerStashProvider("openviking", (config) => new OpenVikingStashProvider(config));
|
|
227
224
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
225
|
+
/**
|
|
226
|
+
* Convert a type:name ref to a viking:// URI for the OpenViking API.
|
|
227
|
+
* Maps the akm asset type back to the OV plural form (e.g. "skill" -> "skills").
|
|
228
|
+
*/
|
|
229
|
+
function refToVikingUri(ref) {
|
|
230
|
+
const colon = ref.indexOf(":");
|
|
231
|
+
if (colon <= 0)
|
|
232
|
+
return `viking://${ref}`;
|
|
233
|
+
const name = ref.slice(colon + 1);
|
|
234
|
+
const type = ref.slice(0, colon);
|
|
235
|
+
const ovDir = AKM_TO_OV_DIR[type] ?? type;
|
|
236
|
+
return `viking://${ovDir}/${name}`;
|
|
232
237
|
}
|
|
238
|
+
/** Reverse map: akm asset type → OpenViking directory name (plural). */
|
|
239
|
+
const AKM_TO_OV_DIR = {
|
|
240
|
+
skill: "skills",
|
|
241
|
+
memory: "memories",
|
|
242
|
+
knowledge: "resources",
|
|
243
|
+
agent: "agents",
|
|
244
|
+
command: "commands",
|
|
245
|
+
script: "scripts",
|
|
246
|
+
};
|
|
233
247
|
function parseOVSearchResponse(result) {
|
|
234
248
|
if (Array.isArray(result))
|
|
235
249
|
return result.filter(isValidOVEntry);
|
|
@@ -311,9 +325,6 @@ function extractNameFromUri(uri) {
|
|
|
311
325
|
const last = segments[segments.length - 1] ?? "unknown";
|
|
312
326
|
return last.replace(/\.[^.]+$/, "");
|
|
313
327
|
}
|
|
314
|
-
function isExpired(mtimeMs, ttlMs) {
|
|
315
|
-
return Date.now() - mtimeMs > ttlMs;
|
|
316
|
-
}
|
|
317
328
|
async function fetchOVJson(url, headers) {
|
|
318
329
|
try {
|
|
319
330
|
const response = await fetchWithRetry(url, { headers }, { timeout: 10_000, retries: 1 });
|
|
@@ -334,4 +345,4 @@ function inferTypeFromUri(uri) {
|
|
|
334
345
|
return OV_TYPE_MAP[firstSegment] ?? "knowledge";
|
|
335
346
|
}
|
|
336
347
|
// ── Exports for testing ─────────────────────────────────────────────────────
|
|
337
|
-
export { OpenVikingStashProvider,
|
|
348
|
+
export { OpenVikingStashProvider, refToVikingUri, parseOVSearchResponse };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Strip terminal control characters from untrusted strings. */
|
|
2
|
+
export function sanitizeString(value, maxLength = 255) {
|
|
3
|
+
if (typeof value !== "string")
|
|
4
|
+
return "";
|
|
5
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — strip control chars from untrusted remote data
|
|
6
|
+
return value.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, maxLength);
|
|
7
|
+
}
|
|
8
|
+
/** Check whether a cached timestamp has exceeded its TTL. */
|
|
9
|
+
export function isExpired(mtimeMs, ttlMs) {
|
|
10
|
+
return Date.now() - mtimeMs > ttlMs;
|
|
11
|
+
}
|
package/dist/stash-search.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { loadConfig } from "./config";
|
|
2
|
-
import {
|
|
2
|
+
import { closeDatabase, openDatabase } from "./db";
|
|
3
|
+
import { searchLocal } from "./local-search";
|
|
3
4
|
import { resolveStashProviders } from "./stash-provider-factory";
|
|
4
5
|
// Eagerly import stash providers to trigger self-registration
|
|
5
6
|
import "./stash-providers/index";
|
|
6
7
|
import { UsageError } from "./errors";
|
|
7
8
|
import { searchRegistry } from "./registry-search";
|
|
8
9
|
import { resolveStashSources } from "./search-source";
|
|
10
|
+
import { insertUsageEvent } from "./usage-events";
|
|
9
11
|
const DEFAULT_LIMIT = 20;
|
|
10
12
|
export async function akmSearch(input) {
|
|
11
13
|
const t0 = Date.now();
|
|
@@ -19,7 +21,7 @@ export async function akmSearch(input) {
|
|
|
19
21
|
if (sources.length === 0) {
|
|
20
22
|
// stashDir: "" is a safe sentinel here — the response carries zero hits
|
|
21
23
|
// and a warning, so no downstream code will try to use the empty path.
|
|
22
|
-
|
|
24
|
+
const response = {
|
|
23
25
|
schemaVersion: 1,
|
|
24
26
|
stashDir: "",
|
|
25
27
|
source,
|
|
@@ -27,12 +29,16 @@ export async function akmSearch(input) {
|
|
|
27
29
|
warnings: ["No stashes configured. Run `akm init` to create your working stash."],
|
|
28
30
|
timing: { totalMs: Date.now() - t0 },
|
|
29
31
|
};
|
|
32
|
+
logSearchEvent(query, response);
|
|
33
|
+
return response;
|
|
30
34
|
}
|
|
31
35
|
// Primary stash directory — used for DB path lookups and as the default
|
|
32
36
|
// stash root. Safe because the empty-sources case is handled above.
|
|
33
37
|
const stashDir = sources[0].path;
|
|
34
|
-
// Resolve additional stash providers (e.g. OpenViking) from config
|
|
35
|
-
|
|
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");
|
|
36
42
|
const localResult = source === "registry"
|
|
37
43
|
? undefined
|
|
38
44
|
: await searchLocal({
|
|
@@ -43,8 +49,8 @@ export async function akmSearch(input) {
|
|
|
43
49
|
sources,
|
|
44
50
|
config,
|
|
45
51
|
});
|
|
46
|
-
//
|
|
47
|
-
const additionalStashResults = source === "registry" || additionalStashProviders.length === 0
|
|
52
|
+
// Pass original case to providers — FTS5 requires lowercase but remote providers handle case themselves
|
|
53
|
+
const additionalStashResults = source === "registry" || additionalStashProviders.length === 0
|
|
48
54
|
? []
|
|
49
55
|
: await Promise.all(additionalStashProviders.map(async (provider) => {
|
|
50
56
|
try {
|
|
@@ -65,7 +71,7 @@ export async function akmSearch(input) {
|
|
|
65
71
|
const allStashHits = mergeStashHits(localResult?.hits ?? [], additionalHits, limit);
|
|
66
72
|
const localWarnings = [...(localResult?.warnings ?? []), ...additionalWarnings];
|
|
67
73
|
const hasResults = allStashHits.length > 0;
|
|
68
|
-
|
|
74
|
+
const response = {
|
|
69
75
|
schemaVersion: 1,
|
|
70
76
|
stashDir,
|
|
71
77
|
source,
|
|
@@ -74,9 +80,14 @@ export async function akmSearch(input) {
|
|
|
74
80
|
warnings: localWarnings.length > 0 ? localWarnings : undefined,
|
|
75
81
|
timing: { totalMs: Date.now() - t0, rankMs: localResult?.rankMs, embedMs: localResult?.embedMs },
|
|
76
82
|
};
|
|
83
|
+
logSearchEvent(query, response);
|
|
84
|
+
return response;
|
|
77
85
|
}
|
|
78
86
|
const registryHits = (registryResult?.hits ?? []).map((hit) => {
|
|
79
|
-
|
|
87
|
+
// Use the provider-supplied installRef when available (already correctly
|
|
88
|
+
// prefixed), otherwise derive it from source + ref for backward compat.
|
|
89
|
+
const installRef = hit.installRef ??
|
|
90
|
+
(hit.source === "npm" ? `npm:${hit.ref}` : hit.source === "git" ? `git+${hit.ref}` : `github:${hit.ref}`);
|
|
80
91
|
return {
|
|
81
92
|
type: "registry",
|
|
82
93
|
name: hit.title,
|
|
@@ -89,76 +100,118 @@ export async function akmSearch(input) {
|
|
|
89
100
|
};
|
|
90
101
|
});
|
|
91
102
|
if (source === "registry") {
|
|
92
|
-
const
|
|
93
|
-
const hasResults =
|
|
94
|
-
|
|
103
|
+
const slicedRegistryHits = registryHits.slice(0, limit);
|
|
104
|
+
const hasResults = slicedRegistryHits.length > 0;
|
|
105
|
+
const response = {
|
|
95
106
|
schemaVersion: 1,
|
|
96
107
|
stashDir,
|
|
97
108
|
source,
|
|
98
|
-
hits,
|
|
109
|
+
hits: [],
|
|
110
|
+
registryHits: slicedRegistryHits,
|
|
99
111
|
tip: hasResults ? undefined : "No matching registry entries were found.",
|
|
100
112
|
warnings: registryResult?.warnings.length ? registryResult.warnings : undefined,
|
|
101
113
|
timing: { totalMs: Date.now() - t0 },
|
|
102
114
|
};
|
|
115
|
+
logSearchEvent(query, response);
|
|
116
|
+
return response;
|
|
103
117
|
}
|
|
104
118
|
// source === "both"
|
|
105
119
|
const allStashHits = mergeStashHits(localResult?.hits ?? [], additionalHits, limit * 2);
|
|
106
|
-
const mergedHits = mergeSearchHits(allStashHits, registryHits, limit);
|
|
107
120
|
const warnings = [...(localResult?.warnings ?? []), ...additionalWarnings, ...(registryResult?.warnings ?? [])];
|
|
108
|
-
const hasResults =
|
|
109
|
-
|
|
121
|
+
const hasResults = allStashHits.length > 0 || registryHits.length > 0;
|
|
122
|
+
const response = {
|
|
110
123
|
schemaVersion: 1,
|
|
111
124
|
stashDir,
|
|
112
125
|
source,
|
|
113
|
-
hits:
|
|
126
|
+
hits: allStashHits.slice(0, limit),
|
|
127
|
+
registryHits,
|
|
114
128
|
tip: hasResults ? undefined : "No matching stash assets or registry entries were found.",
|
|
115
129
|
warnings: warnings.length ? warnings : undefined,
|
|
116
130
|
timing: { totalMs: Date.now() - t0 },
|
|
117
131
|
};
|
|
132
|
+
logSearchEvent(query, response);
|
|
133
|
+
return response;
|
|
118
134
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Resolve entry IDs by file_path lookup (exact match, not LIKE).
|
|
137
|
+
*/
|
|
138
|
+
function resolveEntryIds(db, hits) {
|
|
139
|
+
const results = [];
|
|
140
|
+
const stmt = db.prepare("SELECT id FROM entries WHERE file_path = ? LIMIT 1");
|
|
141
|
+
for (const hit of hits) {
|
|
142
|
+
try {
|
|
143
|
+
const row = stmt.get(hit.path);
|
|
144
|
+
if (row)
|
|
145
|
+
results.push({ entryId: row.id, ref: hit.ref });
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
/* skip unresolvable */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return results;
|
|
124
152
|
}
|
|
125
|
-
|
|
126
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Fire-and-forget: log a search event to the usage_events table.
|
|
155
|
+
* Never blocks the caller; errors are silently ignored.
|
|
156
|
+
*/
|
|
157
|
+
function logSearchEvent(query, response, existingDb) {
|
|
158
|
+
try {
|
|
159
|
+
const db = existingDb ?? openDatabase();
|
|
160
|
+
try {
|
|
161
|
+
const stashHits = response.hits.filter((h) => h.type !== "registry").slice(0, 50);
|
|
162
|
+
const resolved = resolveEntryIds(db, stashHits);
|
|
163
|
+
for (const { entryId, ref } of resolved) {
|
|
164
|
+
insertUsageEvent(db, {
|
|
165
|
+
event_type: "search",
|
|
166
|
+
query,
|
|
167
|
+
entry_id: entryId,
|
|
168
|
+
entry_ref: ref,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
insertUsageEvent(db, {
|
|
172
|
+
event_type: "search",
|
|
173
|
+
query,
|
|
174
|
+
metadata: JSON.stringify({ resultCount: response.hits.length, resolvedCount: resolved.length }),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
if (!existingDb)
|
|
179
|
+
closeDatabase(db);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
/* fire-and-forget */
|
|
184
|
+
}
|
|
127
185
|
}
|
|
128
|
-
// Re-export for consumers that were already importing from stash-search
|
|
129
|
-
export { buildLocalAction, rendererForType };
|
|
130
186
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
131
187
|
/**
|
|
132
|
-
* Merge
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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.
|
|
137
199
|
*/
|
|
138
200
|
export function mergeStashHits(localHits, additionalHits, limit) {
|
|
139
201
|
if (additionalHits.length === 0)
|
|
140
202
|
return localHits.slice(0, limit);
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
};
|
|
156
|
-
applyRankedList(localHits);
|
|
157
|
-
applyRankedList(additionalHits);
|
|
158
|
-
return [...scoreMap.values()]
|
|
159
|
-
.sort((a, b) => b.score - a.score)
|
|
160
|
-
.slice(0, limit)
|
|
161
|
-
.map((v) => ({ ...v.hit, score: Math.round(v.score * 10000) / 10000 }));
|
|
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);
|
|
162
215
|
}
|
|
163
216
|
function normalizeLimit(limit) {
|
|
164
217
|
if (typeof limit !== "number" || Number.isNaN(limit) || limit <= 0) {
|
|
@@ -177,45 +230,8 @@ export function parseSearchSource(source) {
|
|
|
177
230
|
throw new UsageError(`Invalid value for --source: ${String(source)}. Expected one of: stash|registry|both`);
|
|
178
231
|
}
|
|
179
232
|
/**
|
|
180
|
-
* Merge stash hits and registry hits
|
|
233
|
+
* Merge stash hits and registry hits via simple concatenation.
|
|
181
234
|
*/
|
|
182
235
|
export function mergeSearchHits(localHits, registryHits, limit) {
|
|
183
|
-
|
|
184
|
-
return localHits.slice(0, limit);
|
|
185
|
-
if (localHits.length === 0)
|
|
186
|
-
return registryHits.slice(0, limit);
|
|
187
|
-
const RRF_K = 60;
|
|
188
|
-
const scoreMap = new Map();
|
|
189
|
-
const applyStashList = (hits) => {
|
|
190
|
-
for (let i = 0; i < hits.length; i++) {
|
|
191
|
-
const key = hits[i].path ?? hits[i].ref ?? hits[i].name;
|
|
192
|
-
const rrf = 1 / (RRF_K + i + 1);
|
|
193
|
-
const existing = scoreMap.get(key);
|
|
194
|
-
if (existing) {
|
|
195
|
-
existing.score += rrf;
|
|
196
|
-
}
|
|
197
|
-
else {
|
|
198
|
-
scoreMap.set(key, { hit: hits[i], score: rrf });
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
};
|
|
202
|
-
const applyRegistryList = (hits) => {
|
|
203
|
-
for (let i = 0; i < hits.length; i++) {
|
|
204
|
-
const key = `registry:${hits[i].id ?? hits[i].name}`;
|
|
205
|
-
const rrf = 1 / (RRF_K + i + 1);
|
|
206
|
-
const existing = scoreMap.get(key);
|
|
207
|
-
if (existing) {
|
|
208
|
-
existing.score += rrf;
|
|
209
|
-
}
|
|
210
|
-
else {
|
|
211
|
-
scoreMap.set(key, { hit: hits[i], score: rrf });
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
};
|
|
215
|
-
applyStashList(localHits);
|
|
216
|
-
applyRegistryList(registryHits);
|
|
217
|
-
return [...scoreMap.values()]
|
|
218
|
-
.sort((a, b) => b.score - a.score)
|
|
219
|
-
.slice(0, limit)
|
|
220
|
-
.map((v) => ({ ...v.hit, score: Math.round(v.score * 10000) / 10000 }));
|
|
236
|
+
return [...localHits, ...registryHits].slice(0, limit);
|
|
221
237
|
}
|