@klhapp/skillmux 1.0.0 → 1.1.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/src/clients.ts CHANGED
@@ -1,13 +1,17 @@
1
- import type { Clients, Config } from "./types";
1
+ import type { Clients, Config, RemoteRerankerConfig } from "./types";
2
2
  import { expandHome } from "./config";
3
3
  import type { pipeline as createPipeline } from "@huggingface/transformers";
4
4
 
5
- interface EmbeddingResponse {
6
- data: { index: number; embedding: number[] }[];
7
- }
5
+ export type RemoteErrorKind = "configuration" | "availability" | "protocol";
8
6
 
9
- interface RerankResponse {
10
- results: { index: number; relevance_score: number }[];
7
+ export class RemoteInferenceError extends Error {
8
+ constructor(
9
+ public readonly kind: RemoteErrorKind,
10
+ message: string,
11
+ ) {
12
+ super(message);
13
+ this.name = "RemoteInferenceError";
14
+ }
11
15
  }
12
16
 
13
17
  // Lazy-loaded model instances for in-process ONNX inference
@@ -15,6 +19,220 @@ type FeatureExtractor = Awaited<ReturnType<typeof createPipeline<"feature-extrac
15
19
 
16
20
  let localEmbedder: FeatureExtractor | null = null;
17
21
 
22
+ function authorizationHeaders(
23
+ apiKeyEnv: string | undefined,
24
+ configKey: string,
25
+ ): Record<string, string> {
26
+ if (apiKeyEnv === undefined) return {};
27
+ const apiKey = process.env[apiKeyEnv];
28
+ if (apiKey === undefined || apiKey === "") {
29
+ throw new RemoteInferenceError(
30
+ "configuration",
31
+ `${configKey} names environment variable "${apiKeyEnv}", but it is unset or empty.`,
32
+ );
33
+ }
34
+ return { authorization: `Bearer ${apiKey}` };
35
+ }
36
+
37
+ function httpFailure(surface: string, status: number): RemoteInferenceError {
38
+ const kind: RemoteErrorKind =
39
+ status === 401 || status === 403
40
+ ? "configuration"
41
+ : status === 408 || status === 429 || status >= 500
42
+ ? "availability"
43
+ : "protocol";
44
+ return new RemoteInferenceError(kind, `${surface} returned HTTP ${status}`);
45
+ }
46
+
47
+ function finiteFloat32(
48
+ values: unknown[],
49
+ error: () => Error,
50
+ ): Float32Array {
51
+ const result = new Float32Array(values.length);
52
+ for (let index = 0; index < values.length; index++) {
53
+ const value = values[index];
54
+ if (typeof value !== "number" || !Number.isFinite(value)) throw error();
55
+ result[index] = value;
56
+ if (!Number.isFinite(result[index]!)) throw error();
57
+ }
58
+ return result;
59
+ }
60
+
61
+ function parseEmbeddingVectors(
62
+ value: unknown,
63
+ inputCount: number,
64
+ dimension: number,
65
+ ): Float32Array[] {
66
+ const response = value as { data?: unknown };
67
+ if (
68
+ typeof response !== "object" ||
69
+ response === null ||
70
+ !Array.isArray(response.data) ||
71
+ response.data.length !== inputCount
72
+ ) {
73
+ throw new RemoteInferenceError(
74
+ "protocol",
75
+ "embedding endpoint returned an incomplete data array",
76
+ );
77
+ }
78
+
79
+ const vectors = new Array<Float32Array>(inputCount);
80
+ const seen = new Set<number>();
81
+ for (const value of response.data) {
82
+ const entry = value as { index?: unknown; embedding?: unknown };
83
+ if (
84
+ typeof entry !== "object" ||
85
+ entry === null ||
86
+ !Number.isInteger(entry.index) ||
87
+ (entry.index as number) < 0 ||
88
+ (entry.index as number) >= inputCount ||
89
+ seen.has(entry.index as number) ||
90
+ !Array.isArray(entry.embedding) ||
91
+ entry.embedding.length !== dimension
92
+ ) {
93
+ throw new RemoteInferenceError(
94
+ "protocol",
95
+ "embedding endpoint returned invalid indexed vectors",
96
+ );
97
+ }
98
+ seen.add(entry.index as number);
99
+ vectors[entry.index as number] = finiteFloat32(
100
+ entry.embedding,
101
+ () => new RemoteInferenceError("protocol", "embedding endpoint returned invalid vector values"),
102
+ );
103
+ }
104
+ return vectors;
105
+ }
106
+
107
+ export function parseLocalEmbeddingVectors(
108
+ value: unknown,
109
+ inputCount: number,
110
+ dimension: number,
111
+ ): Float32Array[] {
112
+ if (!Array.isArray(value) || value.length !== inputCount) {
113
+ throw new Error("Embedding model returned an unexpected batch size.");
114
+ }
115
+ return value.map((row) => {
116
+ if (!Array.isArray(row) || row.length !== dimension) {
117
+ throw new Error("Embedding model returned unexpected vector dimensions.");
118
+ }
119
+ return finiteFloat32(
120
+ row,
121
+ () => new Error("Embedding model returned invalid vector values."),
122
+ );
123
+ });
124
+ }
125
+
126
+ function rerankerRequestBody(
127
+ reranker: RemoteRerankerConfig,
128
+ query: string,
129
+ docs: { skill_id: string; text: string }[],
130
+ ): Record<string, unknown> {
131
+ if (reranker.adapter === "jina-v1") {
132
+ return {
133
+ model: reranker.model,
134
+ query,
135
+ documents: docs.map((doc) => doc.text),
136
+ };
137
+ }
138
+ return {
139
+ model: reranker.model,
140
+ query,
141
+ documents: docs.map((doc) => ({
142
+ text: doc.text,
143
+ id: doc.skill_id,
144
+ meta: {},
145
+ })),
146
+ top_n: docs.length,
147
+ return_documents: false,
148
+ };
149
+ }
150
+
151
+ function parseRerankerScores(
152
+ adapter: RemoteRerankerConfig["adapter"],
153
+ value: unknown,
154
+ documentCount: number,
155
+ ): number[] {
156
+ const response = value as { results?: unknown };
157
+ if (
158
+ typeof response !== "object" ||
159
+ response === null ||
160
+ !Array.isArray(response.results) ||
161
+ response.results.length !== documentCount
162
+ ) {
163
+ throw new RemoteInferenceError(
164
+ "protocol",
165
+ `reranker adapter "${adapter}" returned an incomplete results array`,
166
+ );
167
+ }
168
+
169
+ const scores = new Array<number>(documentCount);
170
+ const seen = new Set<number>();
171
+ for (const value of response.results) {
172
+ const result = value as { index?: unknown; relevance_score?: unknown };
173
+ if (
174
+ typeof result !== "object" ||
175
+ result === null ||
176
+ !Number.isInteger(result.index) ||
177
+ (result.index as number) < 0 ||
178
+ (result.index as number) >= documentCount ||
179
+ seen.has(result.index as number) ||
180
+ typeof result.relevance_score !== "number" ||
181
+ !Number.isFinite(result.relevance_score)
182
+ ) {
183
+ throw new RemoteInferenceError(
184
+ "protocol",
185
+ `reranker adapter "${adapter}" returned invalid indexed scores`,
186
+ );
187
+ }
188
+ seen.add(result.index as number);
189
+ scores[result.index as number] = result.relevance_score;
190
+ }
191
+ return scores;
192
+ }
193
+
194
+ async function fetchRerankerScores(
195
+ reranker: RemoteRerankerConfig,
196
+ timeoutMs: number,
197
+ query: string,
198
+ docs: { skill_id: string; text: string }[],
199
+ ): Promise<number[]> {
200
+ if (docs.length === 0) return [];
201
+
202
+ let response: Response;
203
+ try {
204
+ response = await fetch(reranker.endpoint, {
205
+ method: "POST",
206
+ headers: {
207
+ "content-type": "application/json",
208
+ ...authorizationHeaders(reranker.api_key_env, "inference.reranker.api_key_env"),
209
+ },
210
+ body: JSON.stringify(rerankerRequestBody(reranker, query, docs)),
211
+ signal: AbortSignal.timeout(timeoutMs),
212
+ });
213
+ } catch (error) {
214
+ if (error instanceof RemoteInferenceError) throw error;
215
+ throw new RemoteInferenceError(
216
+ "availability",
217
+ `reranker adapter "${reranker.adapter}" request failed`,
218
+ );
219
+ }
220
+ if (!response.ok) {
221
+ throw httpFailure(`reranker adapter "${reranker.adapter}"`, response.status);
222
+ }
223
+
224
+ let parsed: unknown;
225
+ try {
226
+ parsed = await response.json();
227
+ } catch {
228
+ throw new RemoteInferenceError(
229
+ "protocol",
230
+ `reranker adapter "${reranker.adapter}" returned malformed JSON`,
231
+ );
232
+ }
233
+ return parseRerankerScores(reranker.adapter, parsed, docs.length);
234
+ }
235
+
18
236
  function localInference(config: Config) {
19
237
  if (config.inference.mode !== "local") throw new Error("Local inference is not configured.");
20
238
  return config.inference;
@@ -56,37 +274,45 @@ export function createClients(config: Config): Clients {
56
274
  const pipe = await getLocalEmbedder(config);
57
275
  const output = await pipe(texts, { pooling: "mean", normalize: true });
58
276
  const dim = output.dims[1];
59
- if (dim === undefined || output.dims.length !== 2 || output.dims[0] !== texts.length) {
277
+ if (
278
+ dim === undefined ||
279
+ output.dims.length !== 2 ||
280
+ output.dims[0] !== texts.length ||
281
+ dim !== config.inference.embedding.dimension
282
+ ) {
60
283
  throw new Error(`Embedding model returned unexpected dimensions: ${output.dims.join("x")}`);
61
284
  }
62
- const result: Float32Array[] = [];
63
- for (let i = 0; i < texts.length; i++) {
64
- const row = output.slice(i, null).tolist();
65
- if (!Array.isArray(row) || row.some((value) => typeof value !== "number")) {
66
- throw new Error("Embedding model returned non-numeric values.");
67
- }
68
- result.push(Float32Array.from(row));
69
- }
70
- return result;
285
+ const rows = Array.from({ length: texts.length }, (_, index) =>
286
+ output.slice(index, null).tolist(),
287
+ );
288
+ return parseLocalEmbeddingVectors(rows, texts.length, dim);
71
289
  }
72
290
 
73
291
  const embedding = config.inference.embedding;
74
- const apiKey = embedding.api_key_env ? process.env[embedding.api_key_env] : undefined;
75
- const cleanBase = embedding.base_url.replace(/\/$/, "");
76
- const embedPath = cleanBase.endsWith("/v1") ? "/embeddings" : "/v1/embeddings";
77
- const response = await fetch(`${cleanBase}${embedPath}`, {
78
- method: "POST",
79
- headers: {
80
- "content-type": "application/json",
81
- ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
82
- },
83
- body: JSON.stringify({ model: embedding.model, input: texts }),
84
- signal: AbortSignal.timeout(config.inference.timeout_ms),
85
- });
86
- if (!response.ok) throw new Error(`embeddings endpoint returned ${response.status}`);
87
- const parsed = (await response.json()) as EmbeddingResponse;
88
- const byIndex = [...parsed.data].sort((a, b) => a.index - b.index);
89
- return byIndex.map((d) => Float32Array.from(d.embedding));
292
+ let response: Response;
293
+ try {
294
+ response = await fetch(embedding.endpoint, {
295
+ method: "POST",
296
+ headers: {
297
+ "content-type": "application/json",
298
+ ...authorizationHeaders(embedding.api_key_env, "inference.embedding.api_key_env"),
299
+ },
300
+ body: JSON.stringify({ model: embedding.model, input: texts }),
301
+ signal: AbortSignal.timeout(config.inference.timeout_ms),
302
+ });
303
+ } catch (error) {
304
+ if (error instanceof RemoteInferenceError) throw error;
305
+ throw new RemoteInferenceError("availability", "embedding endpoint request failed");
306
+ }
307
+ if (!response.ok) throw httpFailure("embedding endpoint", response.status);
308
+
309
+ let parsed: unknown;
310
+ try {
311
+ parsed = await response.json();
312
+ } catch {
313
+ throw new RemoteInferenceError("protocol", "embedding endpoint returned malformed JSON");
314
+ }
315
+ return parseEmbeddingVectors(parsed, texts.length, embedding.dimension);
90
316
  },
91
317
  };
92
318
  if (config.inference.mode === "remote" && config.inference.reranker) {
@@ -94,22 +320,12 @@ export function createClients(config: Config): Clients {
94
320
  clients.rerank = async (query, docs) => {
95
321
  const reranker = inference.reranker;
96
322
  if (!reranker) throw new Error("Reranker is not configured.");
97
- const apiKey = reranker.api_key_env ? process.env[reranker.api_key_env] : undefined;
98
- const response = await fetch(`${reranker.base_url.replace(/\/$/, "")}/rerank`, {
99
- method: "POST",
100
- headers: { "content-type": "application/json", ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}) },
101
- body: JSON.stringify({
102
- model: reranker.model,
103
- query,
104
- documents: docs.map((d) => d.text),
105
- }),
106
- signal: AbortSignal.timeout(inference.timeout_ms),
107
- });
108
- if (!response.ok) throw new Error(`rerank endpoint returned ${response.status}`);
109
- const parsed = (await response.json()) as RerankResponse;
110
- const scores = new Array<number>(docs.length).fill(0);
111
- for (const result of parsed.results) scores[result.index] = result.relevance_score;
112
- return scores;
323
+ return fetchRerankerScores(
324
+ reranker,
325
+ inference.timeout_ms,
326
+ query,
327
+ docs,
328
+ );
113
329
  };
114
330
  }
115
331
  return clients;
@@ -0,0 +1,202 @@
1
+ import { expandHome, migrateLegacyPaths, resolveConfigPath } from "../config";
2
+ import { type TargetAdapter } from "../adapters";
3
+ import { type ResolvedTarget } from "../context";
4
+ import { applyConfigInit, planConfigInit, type ConfigInitPlan } from "../setup";
5
+ import { emitSuccess, isInteractive, renderTargetBanner } from "../output";
6
+ import { confirmAction } from "./shared";
7
+ function emitConfigInitOutcome(
8
+ ctx: { isJson: boolean },
9
+ opts: {
10
+ phase: "plan" | "result";
11
+ dryRun: boolean;
12
+ applied: boolean;
13
+ plan: ConfigInitPlan;
14
+ action: "create" | "preserve";
15
+ text: string;
16
+ },
17
+ ): void {
18
+ if (ctx.isJson) {
19
+ console.log(
20
+ JSON.stringify({
21
+ schema_version: 1,
22
+ ok: true,
23
+ command: "config init",
24
+ phase: opts.phase,
25
+ dry_run: opts.dryRun,
26
+ applied: opts.applied,
27
+ plan: {
28
+ config_path: opts.plan.configPath,
29
+ vault_path: opts.plan.vaultPath,
30
+ action: opts.action,
31
+ },
32
+ }),
33
+ );
34
+ return;
35
+ }
36
+ console.log(opts.text);
37
+ }
38
+
39
+ export async function handleConfigCommand(
40
+ adapter: TargetAdapter,
41
+ sub: string,
42
+ args: string[],
43
+ ctx: { target: ResolvedTarget; isJson: boolean; dryRun: boolean },
44
+ ) {
45
+ if (sub === "init") {
46
+ let vaultPath: string | undefined;
47
+ let yes = false;
48
+ for (let i = 0; i < args.length; i++) {
49
+ const option = args[i];
50
+ if (option === "--vault") {
51
+ vaultPath = args[++i];
52
+ if (!vaultPath)
53
+ throw new Error("usage: skillmux config init --vault <path> --yes");
54
+ } else if (option === "--yes") {
55
+ yes = true;
56
+ } else if (option === "--dry-run" || option === "--json") {
57
+ continue;
58
+ } else {
59
+ throw new Error(`unknown config init option: ${option}`);
60
+ }
61
+ }
62
+ if (!vaultPath) {
63
+ if (isInteractive() && !ctx.isJson) {
64
+ vaultPath = "~/skills";
65
+ } else {
66
+ throw new Error("usage: skillmux config init --vault <path> --yes");
67
+ }
68
+ }
69
+
70
+ migrateLegacyPaths();
71
+ const plan = planConfigInit(resolveConfigPath(), expandHome(vaultPath));
72
+ if (plan.action === "preserve") {
73
+ emitConfigInitOutcome(ctx, {
74
+ phase: "result",
75
+ dryRun: ctx.dryRun,
76
+ applied: false,
77
+ plan,
78
+ action: "preserve",
79
+ text: `preserved existing config: ${plan.configPath}`,
80
+ });
81
+ return;
82
+ }
83
+ if (ctx.dryRun) {
84
+ emitConfigInitOutcome(ctx, {
85
+ phase: "plan",
86
+ dryRun: true,
87
+ applied: false,
88
+ plan,
89
+ action: "create",
90
+ text: `config create: ${plan.configPath} (dry-run)`,
91
+ });
92
+ return;
93
+ }
94
+ if (!yes) {
95
+ if (!ctx.isJson && isInteractive()) {
96
+ if (
97
+ !(await confirmAction(
98
+ `Create ${plan.configPath} with vault_path ${plan.vaultPath}?`,
99
+ ))
100
+ ) {
101
+ console.log("config init cancelled; nothing written");
102
+ return;
103
+ }
104
+ } else {
105
+ throw new Error(
106
+ "config initialization requires --yes in noninteractive mode",
107
+ );
108
+ }
109
+ }
110
+
111
+ const result = applyConfigInit(plan);
112
+ emitConfigInitOutcome(ctx, {
113
+ phase: "result",
114
+ dryRun: false,
115
+ applied: result === "created",
116
+ plan,
117
+ action: plan.action,
118
+ text:
119
+ result === "created"
120
+ ? `created ${plan.configPath}`
121
+ : `preserved existing config: ${plan.configPath}`,
122
+ });
123
+ return;
124
+ }
125
+
126
+ if (sub === "show") {
127
+ const data = await adapter.getConfigShow();
128
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, data, () => {
129
+ renderTargetBanner(ctx.target);
130
+ console.log(JSON.stringify(data.effective, null, 2));
131
+ });
132
+ return;
133
+ }
134
+
135
+ if (sub === "get") {
136
+ const key = args[0];
137
+ if (!key) throw new Error("usage: skillmux config get <key>");
138
+ const val = await adapter.getConfigGet(key);
139
+ emitSuccess(
140
+ { isJson: ctx.isJson, target: ctx.target },
141
+ { key, value: val },
142
+ () => {
143
+ console.log(
144
+ typeof val === "object" ? JSON.stringify(val) : String(val),
145
+ );
146
+ },
147
+ );
148
+ return;
149
+ }
150
+
151
+ if (sub === "validate") {
152
+ const res = await adapter.configValidate();
153
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
154
+ console.log(
155
+ res.valid ? "Configuration is valid." : "Configuration is invalid.",
156
+ );
157
+ });
158
+ return;
159
+ }
160
+
161
+ if (sub === "diff") {
162
+ const res = await adapter.configDiff();
163
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
164
+ renderTargetBanner(ctx.target);
165
+ console.log(JSON.stringify(res.diff, null, 2));
166
+ });
167
+ return;
168
+ }
169
+
170
+ if (sub === "set") {
171
+ const key = args[0];
172
+ const value = args[1];
173
+ if (!key || value === undefined) {
174
+ throw new Error("usage: skillmux config set <key> <value> [--dry-run]");
175
+ }
176
+ const res = await adapter.configSet(key, value, { dryRun: ctx.dryRun });
177
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
178
+ renderTargetBanner(ctx.target);
179
+ const prefix = ctx.dryRun ? "[dry-run] " : "";
180
+ console.log(
181
+ `${prefix}${key}: ${JSON.stringify(res.prior_val)} -> ${JSON.stringify(res.resulting_val)}`,
182
+ );
183
+ console.log(
184
+ `Persistence: ${res.persistence}, Application: ${res.application}`,
185
+ );
186
+ });
187
+ return;
188
+ }
189
+
190
+ if (sub === "status") {
191
+ const res = await adapter.configStatus();
192
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
193
+ renderTargetBanner(ctx.target);
194
+ console.log(`Runtime: ${res.runtime}`);
195
+ console.log(`Active revision: ${res.active_revision}`);
196
+ console.log(`Readiness: ${res.readiness.status}`);
197
+ });
198
+ return;
199
+ }
200
+
201
+ throw new Error("usage: skillmux config show");
202
+ }
@@ -0,0 +1,52 @@
1
+ import { expandHome } from "../config";
2
+ import { pinCore, unpinCore, validateManifest, writeManifestAtomic } from "../manifest";
3
+ import { emitSuccess } from "../output";
4
+ import { confirmIfNeeded, loadManifestContext } from "./shared";
5
+ export async function runCore(
6
+ subCommand: string,
7
+ args: string[],
8
+ options: { isJson: boolean; dryRun: boolean },
9
+ ): Promise<void> {
10
+ if (subCommand !== "pin" && subCommand !== "unpin") {
11
+ throw new Error("usage: skillmux core <pin|unpin>");
12
+ }
13
+ const skillIds = args.filter((arg) => !arg.startsWith("-"));
14
+ if (skillIds.length === 0) {
15
+ throw new Error(`usage: skillmux core ${subCommand} <skill_id>... --yes`);
16
+ }
17
+ const yes = args.includes("--yes");
18
+ const { config, vaultPath, manifestPath, manifest } =
19
+ await loadManifestContext();
20
+ let updated = manifest;
21
+ for (const skillId of skillIds) {
22
+ updated =
23
+ subCommand === "pin"
24
+ ? pinCore(updated, skillId)
25
+ : unpinCore(updated, skillId);
26
+ }
27
+ validateManifest(
28
+ updated,
29
+ vaultPath,
30
+ config.local_vault_paths.map(expandHome),
31
+ );
32
+ if (options.dryRun) {
33
+ emitSuccess(
34
+ { isJson: options.isJson },
35
+ { subcommand: subCommand, skill_ids: skillIds },
36
+ () =>
37
+ console.log(`${subCommand}: [core] ${skillIds.join(", ")} (dry-run)`),
38
+ );
39
+ return;
40
+ }
41
+ if (
42
+ !(await confirmIfNeeded({
43
+ confirmed: yes,
44
+ isJson: options.isJson,
45
+ prompt: `${subCommand} ${skillIds.join(", ")} in [core]?`,
46
+ nonInteractiveError: `skillmux core ${subCommand} requires --yes when run non-interactively`,
47
+ }))
48
+ )
49
+ return;
50
+ writeManifestAtomic(manifestPath, updated);
51
+ console.log(`${subCommand}: [core] ${skillIds.join(", ")}`);
52
+ }