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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI-compatible remote embedder.
|
|
3
|
+
*
|
|
4
|
+
* Calls the configured `/embeddings` endpoint and L2-normalizes the returned
|
|
5
|
+
* vectors so the scoring pipeline's L2-to-cosine conversion is correct.
|
|
6
|
+
*/
|
|
7
|
+
import { fetchWithTimeout, isHttpUrl } from "../common";
|
|
8
|
+
const REMOTE_BATCH_SIZE = 100;
|
|
9
|
+
export class RemoteEmbedder {
|
|
10
|
+
config;
|
|
11
|
+
constructor(config) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
}
|
|
14
|
+
async embed(text) {
|
|
15
|
+
const headers = this.buildHeaders();
|
|
16
|
+
const body = {
|
|
17
|
+
input: text,
|
|
18
|
+
model: this.config.model,
|
|
19
|
+
};
|
|
20
|
+
if (this.config.dimension) {
|
|
21
|
+
body.dimensions = this.config.dimension;
|
|
22
|
+
}
|
|
23
|
+
const response = await fetchWithTimeout(normalizeEmbeddingEndpoint(this.config.endpoint), {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers,
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
});
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
const errBody = await response.text().catch(() => "");
|
|
30
|
+
throw new Error(`Embedding request failed (${response.status}): ${errBody}`);
|
|
31
|
+
}
|
|
32
|
+
const json = (await response.json());
|
|
33
|
+
if (!json.data?.[0]?.embedding) {
|
|
34
|
+
throw new Error(`Unexpected embedding response format: missing data[0].embedding.${embeddingEndpointPathHint(this.config.endpoint)}`);
|
|
35
|
+
}
|
|
36
|
+
return l2Normalize(json.data[0].embedding);
|
|
37
|
+
}
|
|
38
|
+
async embedBatch(texts) {
|
|
39
|
+
if (texts.length === 0)
|
|
40
|
+
return [];
|
|
41
|
+
const results = [];
|
|
42
|
+
const headers = this.buildHeaders();
|
|
43
|
+
for (let i = 0; i < texts.length; i += REMOTE_BATCH_SIZE) {
|
|
44
|
+
const batch = texts.slice(i, i + REMOTE_BATCH_SIZE);
|
|
45
|
+
const body = {
|
|
46
|
+
input: batch,
|
|
47
|
+
model: this.config.model,
|
|
48
|
+
};
|
|
49
|
+
if (this.config.dimension) {
|
|
50
|
+
body.dimensions = this.config.dimension;
|
|
51
|
+
}
|
|
52
|
+
const response = await fetchWithTimeout(normalizeEmbeddingEndpoint(this.config.endpoint), {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers,
|
|
55
|
+
body: JSON.stringify(body),
|
|
56
|
+
});
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const respBody = await response.text().catch(() => "");
|
|
59
|
+
throw new Error(`Embedding batch request failed (${response.status}): ${respBody}`);
|
|
60
|
+
}
|
|
61
|
+
const json = (await response.json());
|
|
62
|
+
if (!json.data || json.data.length !== batch.length) {
|
|
63
|
+
throw new Error(`Unexpected embedding batch response: expected ${batch.length} embeddings, got ${json.data?.length ?? 0}.${embeddingEndpointPathHint(this.config.endpoint)}`);
|
|
64
|
+
}
|
|
65
|
+
// Sort by index to guarantee correct order (OpenAI API doesn't guarantee order)
|
|
66
|
+
const sorted = [...json.data].sort((a, b) => a.index - b.index);
|
|
67
|
+
for (const [idx, d] of sorted.entries()) {
|
|
68
|
+
if (!Array.isArray(d.embedding)) {
|
|
69
|
+
throw new Error(`Unexpected embedding at batch index ${idx}: missing or invalid`);
|
|
70
|
+
}
|
|
71
|
+
results.push(l2Normalize(d.embedding));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return results;
|
|
75
|
+
}
|
|
76
|
+
buildHeaders() {
|
|
77
|
+
const headers = { "Content-Type": "application/json" };
|
|
78
|
+
if (this.config.apiKey) {
|
|
79
|
+
headers.Authorization = `Bearer ${this.config.apiKey}`;
|
|
80
|
+
}
|
|
81
|
+
return headers;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* L2-normalize a vector to unit length.
|
|
86
|
+
* Required for remote embeddings because the scoring pipeline's L2-to-cosine
|
|
87
|
+
* conversion formula (1 - distance^2/2) is only correct for unit vectors.
|
|
88
|
+
* The local embedder already normalizes via `normalize: true`.
|
|
89
|
+
*/
|
|
90
|
+
function l2Normalize(vec) {
|
|
91
|
+
const norm = Math.sqrt(vec.reduce((sum, v) => sum + v * v, 0));
|
|
92
|
+
if (norm === 0)
|
|
93
|
+
return vec;
|
|
94
|
+
return vec.map((v) => v / norm);
|
|
95
|
+
}
|
|
96
|
+
export function normalizeEmbeddingEndpoint(endpoint) {
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = new URL(endpoint);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return endpoint;
|
|
103
|
+
}
|
|
104
|
+
const normalizedPath = parsed.pathname.replace(/\/+$/, "");
|
|
105
|
+
if (normalizedPath.endsWith("/embeddings")) {
|
|
106
|
+
return parsed.toString();
|
|
107
|
+
}
|
|
108
|
+
parsed.pathname = normalizedPath ? `${normalizedPath}/embeddings` : "/embeddings";
|
|
109
|
+
return parsed.toString();
|
|
110
|
+
}
|
|
111
|
+
function embeddingEndpointPathHint(endpoint) {
|
|
112
|
+
const normalizedEndpoint = normalizeEmbeddingEndpoint(endpoint);
|
|
113
|
+
if (normalizedEndpoint !== endpoint) {
|
|
114
|
+
return ` Check that your endpoint includes the full embeddings path (for example "${normalizedEndpoint}", not just "${endpoint}").`;
|
|
115
|
+
}
|
|
116
|
+
return "";
|
|
117
|
+
}
|
|
118
|
+
/** Check whether an EmbeddingConnectionConfig has a valid remote endpoint. */
|
|
119
|
+
export function hasRemoteEndpoint(config) {
|
|
120
|
+
return isHttpUrl(config.endpoint);
|
|
121
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared embedder types.
|
|
3
|
+
*
|
|
4
|
+
* Pulled out of `embedder.ts` so concrete implementations (`local.ts`,
|
|
5
|
+
* `remote.ts`) and the cache layer can depend on a small, stable types
|
|
6
|
+
* module without dragging in the facade or a sibling implementation.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Cosine similarity between two embedding vectors.
|
|
10
|
+
*
|
|
11
|
+
* Lives next to {@link EmbeddingVector} so importers (notably `db.ts`)
|
|
12
|
+
* can pull just the math without dragging in the embedder facade and its
|
|
13
|
+
* transitive `@huggingface/transformers` import chain.
|
|
14
|
+
*
|
|
15
|
+
* Returns 0 when the vectors have different dimensions — silently
|
|
16
|
+
* computing on a truncated view would produce meaningless scores.
|
|
17
|
+
*/
|
|
18
|
+
export function cosineSimilarity(a, b) {
|
|
19
|
+
if (a.length !== b.length) {
|
|
20
|
+
warn("cosineSimilarity: vector dimension mismatch (%d vs %d) — re-index recommended", a.length, b.length);
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
const len = a.length;
|
|
24
|
+
if (len === 0)
|
|
25
|
+
return 0;
|
|
26
|
+
let dot = 0;
|
|
27
|
+
let magA = 0;
|
|
28
|
+
let magB = 0;
|
|
29
|
+
for (let i = 0; i < len; i++) {
|
|
30
|
+
dot += a[i] * b[i];
|
|
31
|
+
magA += a[i] * a[i];
|
|
32
|
+
magB += b[i] * b[i];
|
|
33
|
+
}
|
|
34
|
+
const denom = Math.sqrt(magA) * Math.sqrt(magB);
|
|
35
|
+
return denom === 0 ? 0 : dot / denom;
|
|
36
|
+
}
|
|
37
|
+
// Imported lazily to keep this types module dependency-free where possible;
|
|
38
|
+
// `warn` is a thin printf wrapper so the cost is negligible.
|
|
39
|
+
import { warn } from "../warn";
|
package/dist/errors.js
CHANGED
|
@@ -4,30 +4,41 @@
|
|
|
4
4
|
* - ConfigError -> exit 78 (configuration / environment problems)
|
|
5
5
|
* - UsageError -> exit 2 (bad CLI arguments or invalid input)
|
|
6
6
|
* - NotFoundError -> exit 1 (requested resource missing)
|
|
7
|
+
*
|
|
8
|
+
* Each error carries a machine-readable `code` field. Codes are stable
|
|
9
|
+
* identifiers safe to consume from scripts and JSON output. Existing throw
|
|
10
|
+
* sites without an explicit code receive a default code per error class so
|
|
11
|
+
* older call sites continue to compile and behave unchanged.
|
|
7
12
|
*/
|
|
8
13
|
/** Raised when configuration or environment is invalid or missing. */
|
|
9
14
|
export class ConfigError extends Error {
|
|
10
|
-
|
|
15
|
+
code;
|
|
16
|
+
constructor(msg, code = "INVALID_CONFIG_FILE") {
|
|
11
17
|
super(msg);
|
|
12
18
|
this.name = "ConfigError";
|
|
19
|
+
this.code = code;
|
|
13
20
|
// Fixes `instanceof` checks under ES5 transpilation targets.
|
|
14
21
|
Object.setPrototypeOf(this, new.target.prototype);
|
|
15
22
|
}
|
|
16
23
|
}
|
|
17
24
|
/** Raised when the user supplies invalid arguments or input. */
|
|
18
25
|
export class UsageError extends Error {
|
|
19
|
-
|
|
26
|
+
code;
|
|
27
|
+
constructor(msg, code = "INVALID_FLAG_VALUE") {
|
|
20
28
|
super(msg);
|
|
21
29
|
this.name = "UsageError";
|
|
30
|
+
this.code = code;
|
|
22
31
|
// Fixes `instanceof` checks under ES5 transpilation targets.
|
|
23
32
|
Object.setPrototypeOf(this, new.target.prototype);
|
|
24
33
|
}
|
|
25
34
|
}
|
|
26
35
|
/** Raised when a requested resource (asset, entry, file) is not found. */
|
|
27
36
|
export class NotFoundError extends Error {
|
|
28
|
-
|
|
37
|
+
code;
|
|
38
|
+
constructor(msg, code = "ASSET_NOT_FOUND") {
|
|
29
39
|
super(msg);
|
|
30
40
|
this.name = "NotFoundError";
|
|
41
|
+
this.code = code;
|
|
31
42
|
// Fixes `instanceof` checks under ES5 transpilation targets.
|
|
32
43
|
Object.setPrototypeOf(this, new.target.prototype);
|
|
33
44
|
}
|
package/dist/frontmatter.js
CHANGED
|
@@ -11,10 +11,8 @@
|
|
|
11
11
|
*
|
|
12
12
|
* **Limitations**: This is a hand-rolled YAML-subset parser with intentional
|
|
13
13
|
* constraints for simplicity and safety:
|
|
14
|
-
* - **
|
|
15
|
-
* (`[a, b, c]`) are
|
|
16
|
-
* produce an empty string or be skipped. Callers must NOT rely on list-
|
|
17
|
-
* valued frontmatter.
|
|
14
|
+
* - **List support**: YAML block sequences (`- item`) and flow arrays
|
|
15
|
+
* (`[a, b, c]`) are both supported for top-level list-valued keys.
|
|
18
16
|
* - **No nested objects beyond one level**: Only a single level of indented
|
|
19
17
|
* key-value pairs is supported.
|
|
20
18
|
* - **Scalar values only**: string, boolean, and number scalars are supported.
|
|
@@ -26,28 +24,75 @@ export function parseFrontmatter(raw) {
|
|
|
26
24
|
}
|
|
27
25
|
const data = {};
|
|
28
26
|
let currentKey = null;
|
|
27
|
+
/** "scalar" | "list" | "object" | "pending" — "pending" means empty value, mode determined by next line */
|
|
28
|
+
let mode = "scalar";
|
|
29
29
|
let nested = null;
|
|
30
|
+
let currentList = null;
|
|
31
|
+
const flushPending = () => {
|
|
32
|
+
// Called when we start a new top-level key and the previous key was still "pending".
|
|
33
|
+
// An empty-value key followed by another top-level key means it was an empty scalar.
|
|
34
|
+
if (mode === "pending" && currentKey !== null) {
|
|
35
|
+
data[currentKey] = "";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
30
38
|
for (const line of parsedBlock.frontmatter.split(/\r?\n/)) {
|
|
39
|
+
// Block-sequence item: "- value" or " - value" (optional 2-space indent)
|
|
40
|
+
// Only match when the current key is in list or pending mode.
|
|
41
|
+
const seqItem = line.match(/^(?: {2})?- (.*)$/);
|
|
42
|
+
if (seqItem && currentKey !== null && (mode === "list" || mode === "pending")) {
|
|
43
|
+
if (mode === "pending") {
|
|
44
|
+
// First block-sequence item after an empty-value key — switch to list mode
|
|
45
|
+
currentList = [];
|
|
46
|
+
data[currentKey] = currentList;
|
|
47
|
+
mode = "list";
|
|
48
|
+
}
|
|
49
|
+
currentList.push(parseYamlScalar(seqItem[1].trim()));
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// Indented nested key-value (object under a key with empty value)
|
|
31
53
|
const indented = line.match(/^ {2}(\w[\w-]*):\s*(.+)$/);
|
|
32
|
-
if (indented && currentKey &&
|
|
54
|
+
if (indented && currentKey !== null && (mode === "object" || mode === "pending")) {
|
|
55
|
+
if (mode === "pending") {
|
|
56
|
+
// First indented k-v after an empty-value key — switch to object mode
|
|
57
|
+
nested = {};
|
|
58
|
+
data[currentKey] = nested;
|
|
59
|
+
mode = "object";
|
|
60
|
+
}
|
|
33
61
|
nested[indented[1]] = parseYamlScalar(indented[2].trim());
|
|
34
62
|
continue;
|
|
35
63
|
}
|
|
64
|
+
// Top-level key (possibly with inline value)
|
|
36
65
|
const top = line.match(/^(\w[\w-]*):\s*(.*)$/);
|
|
37
66
|
if (!top) {
|
|
38
67
|
continue;
|
|
39
68
|
}
|
|
69
|
+
// Starting a new top-level key — flush any pending empty-value key
|
|
70
|
+
flushPending();
|
|
40
71
|
currentKey = top[1];
|
|
41
72
|
const value = top[2].trim();
|
|
42
73
|
if (value === "") {
|
|
43
|
-
|
|
44
|
-
|
|
74
|
+
// Defer mode decision until we see the next line
|
|
75
|
+
mode = "pending";
|
|
76
|
+
nested = null;
|
|
77
|
+
currentList = null;
|
|
78
|
+
// Don't store anything yet — flushPending will set "" if no continuation
|
|
79
|
+
}
|
|
80
|
+
else if (value.startsWith("[") && value.endsWith("]")) {
|
|
81
|
+
// Inline flow array: tags: [ops, networking]
|
|
82
|
+
mode = "list";
|
|
83
|
+
nested = null;
|
|
84
|
+
currentList = parseFlowArray(value);
|
|
85
|
+
data[currentKey] = currentList;
|
|
45
86
|
}
|
|
46
87
|
else {
|
|
88
|
+
mode = "scalar";
|
|
47
89
|
nested = null;
|
|
90
|
+
currentList = null;
|
|
48
91
|
data[currentKey] = parseYamlScalar(value);
|
|
49
92
|
}
|
|
50
93
|
}
|
|
94
|
+
// Flush the last key if it was still pending (empty value, no continuation)
|
|
95
|
+
flushPending();
|
|
51
96
|
return {
|
|
52
97
|
data,
|
|
53
98
|
content: parsedBlock.content,
|
|
@@ -55,6 +100,15 @@ export function parseFrontmatter(raw) {
|
|
|
55
100
|
bodyStartLine: parsedBlock.bodyStartLine,
|
|
56
101
|
};
|
|
57
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Parse a YAML flow array string like `[a, b, c]` into an array of scalars.
|
|
105
|
+
*/
|
|
106
|
+
function parseFlowArray(value) {
|
|
107
|
+
const inner = value.slice(1, -1).trim();
|
|
108
|
+
if (!inner)
|
|
109
|
+
return [];
|
|
110
|
+
return inner.split(",").map((item) => parseYamlScalar(item.trim()));
|
|
111
|
+
}
|
|
58
112
|
export function parseFrontmatterBlock(raw) {
|
|
59
113
|
// Handle both LF and CRLF line endings throughout.
|
|
60
114
|
// The closing --- may be preceded by \r\n; capture and strip trailing \r
|
package/dist/indexer.js
CHANGED
|
@@ -95,9 +95,15 @@ export async function akmIndex(options) {
|
|
|
95
95
|
: "LLM enhancement disabled.",
|
|
96
96
|
});
|
|
97
97
|
const tLlmEnd = Date.now();
|
|
98
|
-
// Rebuild FTS after all inserts
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
// Rebuild FTS after all inserts. Use incremental mode when this whole
|
|
99
|
+
// index run is incremental — only entries touched by `upsertEntry`
|
|
100
|
+
// since the last rebuild are re-indexed, instead of re-scanning every
|
|
101
|
+
// row on every `akm index` invocation.
|
|
102
|
+
rebuildFts(db, { incremental: isIncremental });
|
|
103
|
+
onProgress({
|
|
104
|
+
phase: "fts",
|
|
105
|
+
message: isIncremental ? "Rebuilt full-text search index (dirty rows only)." : "Rebuilt full-text search index.",
|
|
106
|
+
});
|
|
101
107
|
const tFtsEnd = Date.now();
|
|
102
108
|
// Re-link detached usage_events to their new entry_ids via entry_ref.
|
|
103
109
|
// entry_ref is "type:name" (e.g., "skill:code-review"), entry_key is "stashDir:type:name".
|
|
@@ -362,13 +368,17 @@ async function indexEntries(db, allStashSources, isIncremental, builtAtMs, doFul
|
|
|
362
368
|
async function enhanceDirsWithLlm(db, config, dirsNeedingLlm) {
|
|
363
369
|
if (!config.llm || dirsNeedingLlm.length === 0)
|
|
364
370
|
return;
|
|
371
|
+
// Aggregate per-entry failures so a misconfigured LLM endpoint surfaces
|
|
372
|
+
// as a single visible warning instead of silently degrading every entry
|
|
373
|
+
// and leaving the user wondering why nothing got enhanced.
|
|
374
|
+
const summary = { attempted: 0, succeeded: 0, failureSamples: [] };
|
|
365
375
|
for (const { dirPath, files, currentStashDir, stash: originalStash } of dirsNeedingLlm) {
|
|
366
376
|
// Only enhance generated entries; user-provided overrides should not be overwritten
|
|
367
377
|
const generatedEntries = originalStash.entries.filter((e) => e.quality === "generated");
|
|
368
378
|
if (generatedEntries.length === 0)
|
|
369
379
|
continue;
|
|
370
380
|
const generatedStash = { entries: generatedEntries };
|
|
371
|
-
const enhanced = await enhanceStashWithLlm(config.llm, generatedStash, files);
|
|
381
|
+
const enhanced = await enhanceStashWithLlm(config.llm, generatedStash, files, summary);
|
|
372
382
|
// Re-upsert the enhanced entries in a single transaction so a crash
|
|
373
383
|
// cannot leave half the entries updated and the rest stale.
|
|
374
384
|
db.transaction(() => {
|
|
@@ -380,6 +390,16 @@ async function enhanceDirsWithLlm(db, config, dirsNeedingLlm) {
|
|
|
380
390
|
}
|
|
381
391
|
})();
|
|
382
392
|
}
|
|
393
|
+
if (summary.attempted > 0 && summary.succeeded === 0) {
|
|
394
|
+
const sample = summary.failureSamples.length ? ` Example: ${summary.failureSamples[0]}` : "";
|
|
395
|
+
warn(`LLM enhancement failed for all ${summary.attempted} attempted entries — index built without LLM enrichment.` +
|
|
396
|
+
` Check llm.endpoint and llm.model in your config.${sample}`);
|
|
397
|
+
}
|
|
398
|
+
else if (summary.attempted > 0 && summary.succeeded < summary.attempted) {
|
|
399
|
+
const failed = summary.attempted - summary.succeeded;
|
|
400
|
+
const sample = summary.failureSamples.length ? ` Examples: ${summary.failureSamples.join("; ")}` : "";
|
|
401
|
+
warn(`LLM enhancement failed for ${failed}/${summary.attempted} entries — they were left un-enhanced.${sample}`);
|
|
402
|
+
}
|
|
383
403
|
}
|
|
384
404
|
async function generateEmbeddingsForDb(db, config, onProgress) {
|
|
385
405
|
if (config.semanticSearchMode === "off") {
|
|
@@ -569,10 +589,12 @@ function isDirStale(dirPath, currentFiles, previousEntries, builtAtMs) {
|
|
|
569
589
|
}
|
|
570
590
|
return false;
|
|
571
591
|
}
|
|
572
|
-
async function enhanceStashWithLlm(llmConfig, stash, files) {
|
|
592
|
+
async function enhanceStashWithLlm(llmConfig, stash, files, summary) {
|
|
573
593
|
const { enhanceMetadata } = await import("./llm.js");
|
|
574
594
|
const enhanced = [];
|
|
595
|
+
const seenSamples = new Set();
|
|
575
596
|
for (const entry of stash.entries) {
|
|
597
|
+
summary.attempted++;
|
|
576
598
|
try {
|
|
577
599
|
const entryFile = entry.filename
|
|
578
600
|
? (files.find((f) => path.basename(f) === entry.filename) ?? files[0])
|
|
@@ -595,9 +617,15 @@ async function enhanceStashWithLlm(llmConfig, stash, files) {
|
|
|
595
617
|
if (improvements.tags?.length)
|
|
596
618
|
updated.tags = improvements.tags;
|
|
597
619
|
enhanced.push(updated);
|
|
620
|
+
summary.succeeded++;
|
|
598
621
|
}
|
|
599
|
-
catch {
|
|
622
|
+
catch (err) {
|
|
600
623
|
enhanced.push(entry);
|
|
624
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
625
|
+
if (summary.failureSamples.length < 3 && !seenSamples.has(msg)) {
|
|
626
|
+
summary.failureSamples.push(msg);
|
|
627
|
+
seenSamples.add(msg);
|
|
628
|
+
}
|
|
601
629
|
}
|
|
602
630
|
}
|
|
603
631
|
return { entries: enhanced };
|
|
@@ -639,7 +667,10 @@ export function matchEntryToFile(entryName, fileMap, files) {
|
|
|
639
667
|
// Fallback to first file, or null if no files are available
|
|
640
668
|
return files[0] || null;
|
|
641
669
|
}
|
|
642
|
-
|
|
670
|
+
// `buildSearchFields` and `buildSearchText` were previously re-exported from
|
|
671
|
+
// here for backwards compatibility. Importers should now pull them directly
|
|
672
|
+
// from `./search-fields` to avoid loading the indexer's full dependency
|
|
673
|
+
// graph (LLM client, embedder facade) when only the text builder is needed.
|
|
643
674
|
// ── Utility score recomputation ──────────────────────────────────────────────
|
|
644
675
|
/** Retention window for usage events: events older than this are purged. */
|
|
645
676
|
const USAGE_EVENT_RETENTION_DAYS = 90;
|
package/dist/info.js
CHANGED
|
@@ -13,8 +13,8 @@ import { pkgVersion } from "./version";
|
|
|
13
13
|
*/
|
|
14
14
|
export function assembleInfo(options) {
|
|
15
15
|
const config = loadConfig();
|
|
16
|
-
// Asset types
|
|
17
|
-
const assetTypes = getAssetTypes();
|
|
16
|
+
// Asset types (copy into a mutable array — `getAssetTypes()` returns readonly)
|
|
17
|
+
const assetTypes = [...getAssetTypes()];
|
|
18
18
|
const semanticRuntime = readSemanticStatus();
|
|
19
19
|
const semanticStatus = getEffectiveSemanticStatus(config, semanticRuntime);
|
|
20
20
|
// Search modes
|
package/dist/install-audit.js
CHANGED
|
@@ -346,16 +346,31 @@ function splitAllowedFindings(findings, ref, allowedFindings) {
|
|
|
346
346
|
return { findings: active, waivedFindings: waived };
|
|
347
347
|
}
|
|
348
348
|
function matchesAllowedFinding(finding, ref, allowedFindings) {
|
|
349
|
+
// Normalize paths so a waiver written against `scripts/setup.sh` matches
|
|
350
|
+
// a finding emitted as `./scripts/setup.sh` or `scripts//setup.sh`. On
|
|
351
|
+
// Windows we also fold case, mirroring `isWithin`'s comparison rules.
|
|
352
|
+
const findingPathNormalized = normalizeWaiverPath(finding.file);
|
|
349
353
|
return allowedFindings.some((allowed) => {
|
|
350
354
|
if (allowed.id !== finding.id)
|
|
351
355
|
return false;
|
|
352
356
|
if (allowed.ref && allowed.ref !== ref)
|
|
353
357
|
return false;
|
|
354
|
-
if (allowed.path && allowed.path !==
|
|
358
|
+
if (allowed.path && normalizeWaiverPath(allowed.path) !== findingPathNormalized)
|
|
355
359
|
return false;
|
|
356
360
|
return true;
|
|
357
361
|
});
|
|
358
362
|
}
|
|
363
|
+
function normalizeWaiverPath(value) {
|
|
364
|
+
if (!value)
|
|
365
|
+
return value;
|
|
366
|
+
// Strip a leading `./`, collapse duplicate slashes via path.normalize, and
|
|
367
|
+
// POSIX-ify so Windows path separators don't trigger spurious mismatches.
|
|
368
|
+
const normalized = path
|
|
369
|
+
.normalize(value)
|
|
370
|
+
.replace(/\\/g, "/")
|
|
371
|
+
.replace(/^\.\/+/, "");
|
|
372
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
373
|
+
}
|
|
359
374
|
function addUrlLabels(labels, rawUrl) {
|
|
360
375
|
if (!rawUrl)
|
|
361
376
|
return;
|
|
@@ -10,9 +10,11 @@ import { resolveStashDir } from "./common";
|
|
|
10
10
|
import { loadConfig } from "./config";
|
|
11
11
|
import { NotFoundError, UsageError } from "./errors";
|
|
12
12
|
import { akmIndex } from "./indexer";
|
|
13
|
+
import { auditInstallCandidate, deriveRegistryLabels, enforceRegistryInstallPolicy, formatInstallAuditFailure, } from "./install-audit";
|
|
13
14
|
import { removeLockEntry, upsertLockEntry } from "./lockfile";
|
|
14
|
-
import { installRegistryRef, removeInstalledRegistryEntry, upsertInstalledRegistryEntry } from "./registry-install";
|
|
15
15
|
import { parseRegistryRef } from "./registry-resolve";
|
|
16
|
+
import { removeInstalledRegistryEntry, upsertInstalledRegistryEntry } from "./stash-add";
|
|
17
|
+
import { syncFromRef } from "./stash-providers/sync-from-ref";
|
|
16
18
|
import { removeStash } from "./stash-source-manage";
|
|
17
19
|
export async function akmListSources(input) {
|
|
18
20
|
const stashDir = input?.stashDir ?? resolveStashDir();
|
|
@@ -64,7 +66,7 @@ export async function akmListSources(input) {
|
|
|
64
66
|
export async function akmRemove(input) {
|
|
65
67
|
const target = input.target.trim();
|
|
66
68
|
if (!target)
|
|
67
|
-
throw new UsageError("Target is required. Provide the source id, ref, path, URL, or name (e.g. `akm remove npm:@scope/
|
|
69
|
+
throw new UsageError("Target is required. Provide the source id, ref, path, URL, or name (e.g. `akm remove npm:@scope/stash` or `akm remove ~/my-stash`).");
|
|
68
70
|
const stashDir = input.stashDir ?? resolveStashDir();
|
|
69
71
|
const config = loadConfig();
|
|
70
72
|
const installed = config.installed ?? [];
|
|
@@ -138,26 +140,58 @@ export async function akmUpdate(input) {
|
|
|
138
140
|
const force = input?.force === true;
|
|
139
141
|
const installedEntries = loadConfig().installed ?? [];
|
|
140
142
|
const selectedEntries = selectTargets(installedEntries, target, all);
|
|
143
|
+
const auditConfig = loadConfig();
|
|
141
144
|
const processed = [];
|
|
142
145
|
for (const entry of selectedEntries) {
|
|
143
146
|
if (force && shouldCleanupCache(entry)) {
|
|
144
147
|
cleanupDirectoryBestEffort(entry.cacheDir);
|
|
145
148
|
}
|
|
146
|
-
const
|
|
147
|
-
|
|
149
|
+
const synced = await syncFromRef(entry.ref, { force });
|
|
150
|
+
// Mirror the post-sync audit hook from akmAdd so `akm update` can't
|
|
151
|
+
// silently land malicious content during refresh.
|
|
152
|
+
const registryLabels = deriveRegistryLabels({
|
|
153
|
+
source: synced.source,
|
|
154
|
+
ref: synced.ref,
|
|
155
|
+
artifactUrl: synced.artifactUrl,
|
|
156
|
+
});
|
|
157
|
+
enforceRegistryInstallPolicy(registryLabels, auditConfig, entry.ref);
|
|
158
|
+
const audit = auditInstallCandidate({
|
|
159
|
+
rootDir: synced.extractedDir,
|
|
160
|
+
source: synced.source,
|
|
161
|
+
ref: synced.ref,
|
|
162
|
+
registryLabels,
|
|
163
|
+
config: auditConfig,
|
|
164
|
+
});
|
|
165
|
+
if (audit.blocked) {
|
|
166
|
+
throw new Error(formatInstallAuditFailure(synced.ref, audit));
|
|
167
|
+
}
|
|
168
|
+
const installedEntry = {
|
|
169
|
+
id: synced.id,
|
|
170
|
+
source: synced.source,
|
|
171
|
+
ref: synced.ref,
|
|
172
|
+
artifactUrl: synced.artifactUrl,
|
|
173
|
+
resolvedVersion: synced.resolvedVersion,
|
|
174
|
+
resolvedRevision: synced.resolvedRevision,
|
|
175
|
+
stashRoot: synced.contentDir,
|
|
176
|
+
cacheDir: synced.cacheDir,
|
|
177
|
+
installedAt: synced.syncedAt,
|
|
178
|
+
writable: synced.writable ?? entry.writable,
|
|
179
|
+
...(entry.wikiName ? { wikiName: entry.wikiName } : {}),
|
|
180
|
+
};
|
|
181
|
+
upsertInstalledRegistryEntry(installedEntry);
|
|
148
182
|
await upsertLockEntry({
|
|
149
|
-
id:
|
|
150
|
-
source:
|
|
151
|
-
ref:
|
|
152
|
-
resolvedVersion:
|
|
153
|
-
resolvedRevision:
|
|
154
|
-
integrity:
|
|
183
|
+
id: synced.id,
|
|
184
|
+
source: synced.source,
|
|
185
|
+
ref: synced.ref,
|
|
186
|
+
resolvedVersion: synced.resolvedVersion,
|
|
187
|
+
resolvedRevision: synced.resolvedRevision,
|
|
188
|
+
integrity: synced.integrity ?? (synced.source === "local" ? "local" : undefined),
|
|
155
189
|
});
|
|
156
|
-
if (entry.cacheDir !==
|
|
190
|
+
if (entry.cacheDir !== synced.cacheDir && shouldCleanupCache(entry)) {
|
|
157
191
|
cleanupDirectoryBestEffort(entry.cacheDir);
|
|
158
192
|
}
|
|
159
|
-
const versionChanged = (entry.resolvedVersion ?? "") !== (
|
|
160
|
-
const revisionChanged = (entry.resolvedRevision ?? "") !== (
|
|
193
|
+
const versionChanged = (entry.resolvedVersion ?? "") !== (synced.resolvedVersion ?? "");
|
|
194
|
+
const revisionChanged = (entry.resolvedRevision ?? "") !== (synced.resolvedRevision ?? "");
|
|
161
195
|
processed.push({
|
|
162
196
|
id: entry.id,
|
|
163
197
|
source: entry.source,
|
|
@@ -167,7 +201,7 @@ export async function akmUpdate(input) {
|
|
|
167
201
|
resolvedRevision: entry.resolvedRevision,
|
|
168
202
|
cacheDir: entry.cacheDir,
|
|
169
203
|
},
|
|
170
|
-
installed:
|
|
204
|
+
installed: { ...installedEntry, extractedDir: synced.extractedDir, audit },
|
|
171
205
|
changed: {
|
|
172
206
|
version: versionChanged,
|
|
173
207
|
revision: revisionChanged,
|
|
@@ -250,14 +284,6 @@ function tryResolveInstalledTarget(installed, target) {
|
|
|
250
284
|
}
|
|
251
285
|
return undefined;
|
|
252
286
|
}
|
|
253
|
-
function toInstalledEntry(status) {
|
|
254
|
-
// KitInstallStatus extends InstalledKitEntry; omit transient install-only fields.
|
|
255
|
-
const { extractedDir: _extractedDir, audit: _audit, ...base } = status;
|
|
256
|
-
return base;
|
|
257
|
-
}
|
|
258
|
-
function toInstallStatus(status) {
|
|
259
|
-
return { ...status };
|
|
260
|
-
}
|
|
261
287
|
function cleanupDirectoryBestEffort(target) {
|
|
262
288
|
try {
|
|
263
289
|
fs.rmSync(target, { recursive: true, force: true });
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Low-level OpenAI-compatible chat completions client and capability probing.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `llm.ts` to keep the transport-layer concerns (HTTP request,
|
|
5
|
+
* response parsing, JSON-fence stripping, capability probe, availability
|
|
6
|
+
* check) separate from higher-level metadata-enhancement workflows.
|
|
7
|
+
*
|
|
8
|
+
* `llm.ts` re-exports everything from this module for backward compatibility.
|
|
9
|
+
*/
|
|
10
|
+
import { fetchWithTimeout } from "./common";
|
|
11
|
+
export async function chatCompletion(config, messages, options) {
|
|
12
|
+
const headers = { "Content-Type": "application/json" };
|
|
13
|
+
if (config.apiKey) {
|
|
14
|
+
headers.Authorization = `Bearer ${config.apiKey}`;
|
|
15
|
+
}
|
|
16
|
+
const response = await fetchWithTimeout(config.endpoint, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers,
|
|
19
|
+
body: JSON.stringify({
|
|
20
|
+
model: config.model,
|
|
21
|
+
messages,
|
|
22
|
+
temperature: options?.temperature ?? config.temperature ?? 0.3,
|
|
23
|
+
max_tokens: options?.maxTokens ?? config.maxTokens ?? 512,
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
const body = await response.text().catch(() => "");
|
|
28
|
+
throw new Error(`LLM request failed (${response.status}): ${body}`);
|
|
29
|
+
}
|
|
30
|
+
const json = (await response.json());
|
|
31
|
+
return json.choices?.[0]?.message?.content?.trim() ?? "";
|
|
32
|
+
}
|
|
33
|
+
/** Strip leading/trailing markdown code fences from an LLM response. */
|
|
34
|
+
export function stripJsonFences(raw) {
|
|
35
|
+
return raw
|
|
36
|
+
.trim()
|
|
37
|
+
.replace(/^```(?:json)?\s*\n?/i, "")
|
|
38
|
+
.replace(/\n?```\s*$/i, "")
|
|
39
|
+
.trim();
|
|
40
|
+
}
|
|
41
|
+
/** Parse a possibly-fenced JSON response. Returns undefined if invalid. */
|
|
42
|
+
export function parseJsonResponse(raw) {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(stripJsonFences(raw));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// ── Availability check ──────────────────────────────────────────────────────
|
|
51
|
+
/**
|
|
52
|
+
* Check if the LLM endpoint is reachable.
|
|
53
|
+
*/
|
|
54
|
+
export async function isLlmAvailable(config) {
|
|
55
|
+
try {
|
|
56
|
+
const result = await chatCompletion(config, [{ role: "user", content: "Respond with just the word: ok" }]);
|
|
57
|
+
return result.length > 0;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// ── Capability probe ────────────────────────────────────────────────────────
|
|
64
|
+
/**
|
|
65
|
+
* Ask the model to emit a strict JSON object so we know whether the knowledge
|
|
66
|
+
* wiki ingest/lint flows can rely on structured output. Failure is non-fatal —
|
|
67
|
+
* the caller can fall back to assist-only mode.
|
|
68
|
+
*/
|
|
69
|
+
export async function probeLlmCapabilities(config) {
|
|
70
|
+
try {
|
|
71
|
+
const raw = await chatCompletion(config, [
|
|
72
|
+
{
|
|
73
|
+
role: "system",
|
|
74
|
+
content: "You return only valid JSON. No prose, no markdown fences.",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
role: "user",
|
|
78
|
+
content: 'Return exactly this JSON object and nothing else: {"ok": true, "ingest": true, "lint": true}',
|
|
79
|
+
},
|
|
80
|
+
], { maxTokens: 64, temperature: 0 });
|
|
81
|
+
if (!raw)
|
|
82
|
+
return { reachable: false, structuredOutput: false, error: "empty response" };
|
|
83
|
+
const parsed = parseJsonResponse(raw);
|
|
84
|
+
return {
|
|
85
|
+
reachable: true,
|
|
86
|
+
structuredOutput: Boolean(parsed && parsed.ok === true),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
return { reachable: false, structuredOutput: false, error: err instanceof Error ? err.message : String(err) };
|
|
91
|
+
}
|
|
92
|
+
}
|