@klhapp/skillmux 1.3.4 → 1.4.1
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 -0
- package/README.md +64 -23
- package/docs/README.md +42 -17
- package/docs/assets/architecture.svg +42 -44
- package/docs/cli.md +49 -7
- package/docs/concepts.md +42 -19
- package/docs/configuration.md +53 -7
- package/docs/deployment.md +76 -20
- package/docs/getting-started.md +62 -18
- package/docs/mcp-routing.md +11 -4
- package/docs/releasing.md +20 -6
- package/docs/skill-management.md +14 -9
- package/docs/troubleshooting.md +85 -5
- package/package.json +1 -1
- package/src/cli.ts +61 -6
- package/src/commands/config.ts +2 -0
- package/src/config-service.ts +8 -0
- package/src/config-watcher.ts +11 -8
- package/src/deployment.ts +39 -0
- package/src/doctor.ts +47 -18
- package/src/metrics.ts +18 -0
- package/src/output.ts +5 -1
- package/src/router-core.ts +6 -1
- package/src/server.ts +14 -2
package/docs/troubleshooting.md
CHANGED
|
@@ -10,8 +10,67 @@ skillmux config validate
|
|
|
10
10
|
|
|
11
11
|
Add `--json` when you need machine-readable diagnostics.
|
|
12
12
|
|
|
13
|
+
## Startup without configuration
|
|
14
|
+
|
|
15
|
+
`skillmux serve` and `skillmux serve --transport http` do not require a config
|
|
16
|
+
file or an existing `~/.config/skillmux` directory. If you need a specific
|
|
17
|
+
vault, remote inference, or HTTP policy, create a config with `skillmux config
|
|
18
|
+
init --vault ~/skills --yes`; otherwise the server uses its defaults. A missing
|
|
19
|
+
optional config parent disables live reload until the next server start. For a
|
|
20
|
+
malformed watched config, check the reported reload error—the running server
|
|
21
|
+
continues with its last known good configuration.
|
|
22
|
+
|
|
23
|
+
## Docker rejected a CLI command
|
|
24
|
+
|
|
25
|
+
The server image can inspect and serve a mounted vault, but it cannot manage
|
|
26
|
+
agent directories or mutate host configuration. Its help lists the supported
|
|
27
|
+
server commands. For `init`, `sync`, install or pin management, model
|
|
28
|
+
downloads, contexts, calibration, evaluation, project/target/local-vault
|
|
29
|
+
management, or `config init`/`config set`, install and use the host CLI:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
bun add -g @klhapp/skillmux
|
|
33
|
+
skillmux <the-command Docker rejected>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The rejection exits 2. With `--json`, check
|
|
37
|
+
`error.code == "CONTAINER_COMMAND_UNSUPPORTED"`, then use
|
|
38
|
+
`error.details.recommended_host_command`; see the
|
|
39
|
+
[container command contract](deployment.md#container-command-contract).
|
|
40
|
+
|
|
41
|
+
## Installation failures
|
|
42
|
+
|
|
43
|
+
### Standalone executable checksum verification fails
|
|
44
|
+
|
|
45
|
+
Do not install the downloaded file. Confirm that the command still uses the
|
|
46
|
+
pinned release and that the detected architecture is correct:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
uname -m
|
|
50
|
+
sha256sum skillmux-linux-amd64
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Download the file again with the [standalone installation procedure](getting-started.md#install-the-cli).
|
|
54
|
+
For GitHub build-provenance verification instead of the published SHA-256
|
|
55
|
+
digest, use the [GitHub CLI attestation procedure](getting-started.md#install-with-github-cli-attestation).
|
|
56
|
+
|
|
57
|
+
### `skillmux: command not found` after installation
|
|
58
|
+
|
|
59
|
+
The standalone installer defaults to `~/.local/bin`. Add it to the shell's
|
|
60
|
+
`PATH`, restart the shell, then run `skillmux --version`. Alternatively,
|
|
61
|
+
install to a user-writable directory already on `PATH` with
|
|
62
|
+
`SKILLMUX_BIN_DIR=/path/on/PATH`; use `sudo install` only when you explicitly
|
|
63
|
+
want a system-wide installation.
|
|
64
|
+
|
|
13
65
|
## Vault failures
|
|
14
66
|
|
|
67
|
+
### Checkouts are out of date
|
|
68
|
+
|
|
69
|
+
Skillmux reads the configured vault checkout; it does not pull, push,
|
|
70
|
+
replicate, or determine freshness between checkouts. Update the Git-backed
|
|
71
|
+
vault source of truth and use your Git or deployment process to update the
|
|
72
|
+
affected checkout, then rerun `skillmux index` or `skillmux sync` as needed.
|
|
73
|
+
|
|
15
74
|
### Vault path does not exist
|
|
16
75
|
|
|
17
76
|
Check the effective path and its source:
|
|
@@ -84,8 +143,9 @@ target for the current machine instead of reusing the other machine's path.
|
|
|
84
143
|
|
|
85
144
|
### A local-overlay skill cannot be pinned
|
|
86
145
|
|
|
87
|
-
Core and project pins must exist in the
|
|
88
|
-
the skill there before pinning it. Routed lookup can still serve the
|
|
146
|
+
Core and project pins must exist in the configured `vault_path` checkout. Copy
|
|
147
|
+
or commit the skill there before pinning it. Routed lookup can still serve the
|
|
148
|
+
overlay.
|
|
89
149
|
|
|
90
150
|
## Retrieval failures
|
|
91
151
|
|
|
@@ -112,8 +172,8 @@ step depends on the installation:
|
|
|
112
172
|
| Installation | Expected action |
|
|
113
173
|
| --- | --- |
|
|
114
174
|
| Skillmux CLI with local inference | Download the local model and rebuild the index |
|
|
115
|
-
|
|
|
116
|
-
|
|
|
175
|
+
| Skillmux server (full image) | Inspect `doctor`, `/health/ready`, or `skill_router_deployment_info` for `image_variant=full`; do not infer it from the tag |
|
|
176
|
+
| Skillmux server (slim image) | Configure remote embeddings or keep lexical fallback |
|
|
117
177
|
|
|
118
178
|
For a Skillmux CLI installation, run:
|
|
119
179
|
|
|
@@ -158,7 +218,7 @@ Add the browser origin to `server.allowed_origins`. The value must match the
|
|
|
158
218
|
request's `Origin` header. Curl and server-to-server clients omit this header
|
|
159
219
|
and do not use the CORS list.
|
|
160
220
|
|
|
161
|
-
###
|
|
221
|
+
### MCP client receives `401`
|
|
162
222
|
|
|
163
223
|
Confirm `server.auth_enabled = true`, export the environment variable named by
|
|
164
224
|
`auth_token_env`, and send `Authorization: Bearer <token>`.
|
|
@@ -166,6 +226,26 @@ Confirm `server.auth_enabled = true`, export the environment variable named by
|
|
|
166
226
|
An enabled server with an empty token environment variable returns a server
|
|
167
227
|
configuration error rather than accepting an empty token.
|
|
168
228
|
|
|
229
|
+
### Operator receives `401` from `/admin/v1/*`
|
|
230
|
+
|
|
231
|
+
Administrative authentication is separate from MCP authentication. Confirm
|
|
232
|
+
`server.admin.enabled = true`, export the environment variable named by
|
|
233
|
+
`server.admin.token_env`, and configure the named CLI context with that same
|
|
234
|
+
environment-variable name:
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
export SKILLMUX_PROD_ADMIN_TOKEN="replace-with-admin-token"
|
|
238
|
+
skillmux context add prod \
|
|
239
|
+
--server https://skillmux.example.com \
|
|
240
|
+
--token-env SKILLMUX_PROD_ADMIN_TOKEN
|
|
241
|
+
skillmux --context prod config status
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
An MCP token for `/mcp` cannot authenticate this request, and the administrative
|
|
245
|
+
token cannot authenticate an MCP client. A named context administers the
|
|
246
|
+
deployed server configuration only; use Skillmux CLI on the machine that owns
|
|
247
|
+
client directories for `install`, pinning, or `sync`.
|
|
248
|
+
|
|
169
249
|
### Client receives `429`
|
|
170
250
|
|
|
171
251
|
The rate limiter rejected the request. Read `Retry-After` and the
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -97,6 +97,7 @@ import {
|
|
|
97
97
|
import { createTargetAdapter, type TargetAdapter } from "./adapters";
|
|
98
98
|
import {
|
|
99
99
|
emitSuccess,
|
|
100
|
+
CliError,
|
|
100
101
|
formatJsonEnvelope,
|
|
101
102
|
isInteractive,
|
|
102
103
|
mapExitCode,
|
|
@@ -133,9 +134,6 @@ const KNOWN_COMMANDS = [
|
|
|
133
134
|
"local-vault",
|
|
134
135
|
];
|
|
135
136
|
|
|
136
|
-
const DOCKER_HOST_MANAGEMENT_GUIDANCE =
|
|
137
|
-
"This command manages local Skillmux or agent directories and is not supported inside the Docker image. Install the Skillmux CLI on the host using the Bun package or standalone Linux executable.";
|
|
138
|
-
|
|
139
137
|
function isDockerHostManagementCommand(command: string, subCommand: string): boolean {
|
|
140
138
|
if (
|
|
141
139
|
[
|
|
@@ -158,6 +156,33 @@ function isDockerHostManagementCommand(command: string, subCommand: string): boo
|
|
|
158
156
|
return command === "config" && ["init", "set"].includes(subCommand);
|
|
159
157
|
}
|
|
160
158
|
|
|
159
|
+
function containerCommandUnsupported(command: string, subCommand: string): CliError {
|
|
160
|
+
const rejectedCommand = [command, subCommand].filter(Boolean).join(" ");
|
|
161
|
+
const recommendedHostCommand = `skillmux ${rejectedCommand}`;
|
|
162
|
+
const guide = "docs/deployment.md";
|
|
163
|
+
const documentation =
|
|
164
|
+
"https://github.com/klhq/skillmux/blob/main/docs/deployment.md#container-command-contract";
|
|
165
|
+
return new CliError(
|
|
166
|
+
`\`skillmux ${rejectedCommand}\` manages host agent directories and cannot run in the Skillmux server image.\n\n` +
|
|
167
|
+
"Install the host CLI:\n" +
|
|
168
|
+
" bun add -g @klhapp/skillmux\n\n" +
|
|
169
|
+
"Then run:\n" +
|
|
170
|
+
` ${recommendedHostCommand}\n\n` +
|
|
171
|
+
`See ${guide} for server deployment examples.`,
|
|
172
|
+
2,
|
|
173
|
+
"CONTAINER_COMMAND_UNSUPPORTED",
|
|
174
|
+
{
|
|
175
|
+
// `command` remains for automation written against the first Docker
|
|
176
|
+
// boundary release. `rejected_command` is the explicit contract name.
|
|
177
|
+
command: rejectedCommand,
|
|
178
|
+
rejected_command: rejectedCommand,
|
|
179
|
+
recommended_host_command: recommendedHostCommand,
|
|
180
|
+
guide,
|
|
181
|
+
documentation,
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
161
186
|
async function main() {
|
|
162
187
|
const rawArgv = Bun.argv.slice(2);
|
|
163
188
|
|
|
@@ -167,7 +192,10 @@ async function main() {
|
|
|
167
192
|
let flagContext: string | undefined;
|
|
168
193
|
let flagServer: string | undefined;
|
|
169
194
|
let isDryRun = false;
|
|
170
|
-
|
|
195
|
+
// Global flags do not form part of the command identity reported to users.
|
|
196
|
+
// In particular, `init --json` should recommend `skillmux init`, not a
|
|
197
|
+
// redundant JSON-only host command.
|
|
198
|
+
const subCommand = rawArgv[1]?.startsWith("-") ? "" : rawArgv[1] ?? "";
|
|
171
199
|
const commandArgs = rawArgv.slice(2);
|
|
172
200
|
|
|
173
201
|
const command = rawArgv[0];
|
|
@@ -198,7 +226,7 @@ async function main() {
|
|
|
198
226
|
process.env.RUNNING_IN_DOCKER === "true" &&
|
|
199
227
|
isDockerHostManagementCommand(command, subCommand)
|
|
200
228
|
) {
|
|
201
|
-
handleError(
|
|
229
|
+
handleError(containerCommandUnsupported(command, subCommand), {
|
|
202
230
|
target: resolvedTarget,
|
|
203
231
|
isJson,
|
|
204
232
|
isVerbose,
|
|
@@ -572,7 +600,11 @@ function handleError(
|
|
|
572
600
|
const env = formatJsonEnvelope({
|
|
573
601
|
ok: false,
|
|
574
602
|
target: opts.target,
|
|
575
|
-
error: {
|
|
603
|
+
error: {
|
|
604
|
+
code: err instanceof CliError ? err.code : `EXIT_${code}`,
|
|
605
|
+
message: msg,
|
|
606
|
+
details: err instanceof CliError ? err.details : undefined,
|
|
607
|
+
},
|
|
576
608
|
});
|
|
577
609
|
console.log(JSON.stringify(env));
|
|
578
610
|
} else {
|
|
@@ -590,6 +622,23 @@ function handleError(
|
|
|
590
622
|
}
|
|
591
623
|
|
|
592
624
|
function printHelp(): void {
|
|
625
|
+
if (process.env.RUNNING_IN_DOCKER === "true") {
|
|
626
|
+
console.log(`Skillmux server image
|
|
627
|
+
|
|
628
|
+
Default:
|
|
629
|
+
serve --transport http
|
|
630
|
+
|
|
631
|
+
Supported commands:
|
|
632
|
+
serve, index, doctor, report, scan, skill which
|
|
633
|
+
config show|get|validate|diff|status
|
|
634
|
+
|
|
635
|
+
Native skill management:
|
|
636
|
+
Install the Skillmux CLI on the host for init, install, pinning, and sync.
|
|
637
|
+
|
|
638
|
+
See docs/deployment.md for server deployment examples.`);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
593
642
|
console.log(`usage: skillmux <command> [options]
|
|
594
643
|
|
|
595
644
|
Setup:
|
|
@@ -698,8 +747,14 @@ async function runEval(options: { isJson: boolean }): Promise<void> {
|
|
|
698
747
|
async function runDoctor(options: { isJson: boolean }): Promise<void> {
|
|
699
748
|
const report = await diagnose(await loadConfig());
|
|
700
749
|
emitSuccess({ isJson: options.isJson }, report, () => {
|
|
750
|
+
console.log(`version: ${report.version}`);
|
|
751
|
+
console.log(`runtime: ${report.runtime}`);
|
|
752
|
+
console.log(`image variant: ${report.image_variant ?? "none"}`);
|
|
753
|
+
console.log(`vault path: ${report.vault_path}`);
|
|
754
|
+
console.log(`state directory: ${report.state_dir}`);
|
|
701
755
|
console.log(`inference mode: ${report.mode}`);
|
|
702
756
|
console.log(`routing capability: ${report.capability}`);
|
|
757
|
+
console.log(`retrieval capability: ${report.retrieval_capability}`);
|
|
703
758
|
for (const check of report.checks)
|
|
704
759
|
console.log(
|
|
705
760
|
`${check.ok ? "ok" : "fail"}: ${check.name} - ${check.detail}`,
|
package/src/commands/config.ts
CHANGED
|
@@ -192,6 +192,8 @@ export async function handleConfigCommand(
|
|
|
192
192
|
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
193
193
|
renderTargetBanner(ctx.target);
|
|
194
194
|
console.log(`Runtime: ${res.runtime}`);
|
|
195
|
+
console.log(`Deployment runtime: ${res.deployment_runtime}`);
|
|
196
|
+
console.log(`Image variant: ${res.image_variant ?? "none"}`);
|
|
195
197
|
console.log(`Active revision: ${res.active_revision}`);
|
|
196
198
|
console.log(`Readiness: ${res.readiness.status}`);
|
|
197
199
|
});
|
package/src/config-service.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { existsSync, mkdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { DEFAULT_CONFIG_PATH, expandHome, loadConfig } from "./config";
|
|
5
|
+
import { describeDeployment } from "./deployment";
|
|
5
6
|
import type { Config } from "./types";
|
|
6
7
|
|
|
7
8
|
export type ConfigSource = "default" | "toml" | "environment";
|
|
@@ -32,6 +33,9 @@ export interface ConfigStatusResponse {
|
|
|
32
33
|
readiness: { status: "ready" | "degraded" | "not_ready" | "stopping"; capability: string };
|
|
33
34
|
restart_required_keys: string[];
|
|
34
35
|
runtime: "running" | "not_running";
|
|
36
|
+
version: string;
|
|
37
|
+
deployment_runtime: "host" | "docker";
|
|
38
|
+
image_variant: "full" | "slim" | null;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export const RESTART_REQUIRED_KEYS = [
|
|
@@ -409,6 +413,7 @@ function formatTomlVal(v: unknown): string {
|
|
|
409
413
|
export async function getLocalConfigStatus(configPath?: string): Promise<ConfigStatusResponse> {
|
|
410
414
|
const { effective } = await getEffectiveConfig(configPath);
|
|
411
415
|
const hash = computeHash(effective);
|
|
416
|
+
const deployment = describeDeployment(effective);
|
|
412
417
|
|
|
413
418
|
return {
|
|
414
419
|
target: "local",
|
|
@@ -421,5 +426,8 @@ export async function getLocalConfigStatus(configPath?: string): Promise<ConfigS
|
|
|
421
426
|
readiness: { status: "ready", capability: "hybrid" },
|
|
422
427
|
restart_required_keys: [],
|
|
423
428
|
runtime: "not_running",
|
|
429
|
+
version: deployment.version,
|
|
430
|
+
deployment_runtime: deployment.runtime,
|
|
431
|
+
image_variant: deployment.image_variant,
|
|
424
432
|
};
|
|
425
433
|
}
|
package/src/config-watcher.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, watch } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { loadConfig } from "./config";
|
|
4
4
|
import type { Config } from "./types";
|
|
@@ -123,7 +123,7 @@ export class ConfigWatcher {
|
|
|
123
123
|
};
|
|
124
124
|
private stopped = false;
|
|
125
125
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
126
|
-
private watcher: ReturnType<typeof watch
|
|
126
|
+
private watcher: ReturnType<typeof watch> | undefined;
|
|
127
127
|
|
|
128
128
|
private constructor(
|
|
129
129
|
private readonly tomlPath: string,
|
|
@@ -133,11 +133,13 @@ export class ConfigWatcher {
|
|
|
133
133
|
const dir = dirname(tomlPath);
|
|
134
134
|
const filename = tomlPath.split(/[/\\]/).pop()!;
|
|
135
135
|
|
|
136
|
-
// A config file is optional.
|
|
137
|
-
//
|
|
138
|
-
|
|
136
|
+
// A config file is optional. Do not create its parent just to enable
|
|
137
|
+
// reloads: a native zero-config server must also work with a read-only or
|
|
138
|
+
// entirely absent config location. Reloads stay inactive until a future
|
|
139
|
+
// server start finds a watchable parent directory.
|
|
140
|
+
if (!existsSync(dir)) return;
|
|
139
141
|
|
|
140
|
-
|
|
142
|
+
const watcher = watch(dir, { recursive: false }, (_event, changedName) => {
|
|
141
143
|
if (this.stopped) return;
|
|
142
144
|
// Fire for: the config file itself, or any .tmp variant of it (handles
|
|
143
145
|
// pid-numbered atomics: config.toml.12345.tmp → rename → config.toml).
|
|
@@ -151,8 +153,9 @@ export class ConfigWatcher {
|
|
|
151
153
|
}
|
|
152
154
|
this.scheduleReload();
|
|
153
155
|
});
|
|
156
|
+
this.watcher = watcher;
|
|
154
157
|
|
|
155
|
-
|
|
158
|
+
watcher.on("error", (err) => {
|
|
156
159
|
if (!this.stopped) {
|
|
157
160
|
this.status = { ...this.status, last_reload_error: String(err) };
|
|
158
161
|
this.opts.onError(err);
|
|
@@ -243,7 +246,7 @@ export class ConfigWatcher {
|
|
|
243
246
|
this.debounceTimer = null;
|
|
244
247
|
}
|
|
245
248
|
try {
|
|
246
|
-
this.watcher
|
|
249
|
+
this.watcher?.close();
|
|
247
250
|
} catch {
|
|
248
251
|
// already closed
|
|
249
252
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import packageJson from "../package.json" with { type: "json" };
|
|
2
|
+
import { expandHome } from "./config";
|
|
3
|
+
import type { Config } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface DeploymentIdentity {
|
|
6
|
+
version: string;
|
|
7
|
+
runtime: "host" | "docker";
|
|
8
|
+
image_variant: "full" | "slim" | null;
|
|
9
|
+
vault_path: string;
|
|
10
|
+
state_dir: string;
|
|
11
|
+
inference_mode: Config["inference"]["mode"];
|
|
12
|
+
local_embedding_bundle: string | null;
|
|
13
|
+
remote_embedding_configured: boolean;
|
|
14
|
+
remote_reranker_configured: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function describeDeployment(
|
|
18
|
+
config: Config,
|
|
19
|
+
environment: Record<string, string | undefined> = process.env,
|
|
20
|
+
): DeploymentIdentity {
|
|
21
|
+
const runtime = environment.RUNNING_IN_DOCKER === "true" ? "docker" : "host";
|
|
22
|
+
const variant = environment.SKILLMUX_IMAGE_VARIANT;
|
|
23
|
+
const imageVariant = runtime === "docker" && (variant === "full" || variant === "slim")
|
|
24
|
+
? variant
|
|
25
|
+
: null;
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
version: packageJson.version,
|
|
29
|
+
runtime,
|
|
30
|
+
image_variant: imageVariant,
|
|
31
|
+
vault_path: expandHome(config.vault_path),
|
|
32
|
+
state_dir: expandHome(config.state_dir),
|
|
33
|
+
inference_mode: config.inference.mode,
|
|
34
|
+
local_embedding_bundle: config.inference.mode === "local" ? config.inference.bundle : null,
|
|
35
|
+
remote_embedding_configured: config.inference.mode === "remote",
|
|
36
|
+
remote_reranker_configured:
|
|
37
|
+
config.inference.mode === "remote" && !!config.inference.reranker,
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/doctor.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from "node:fs";
|
|
|
2
2
|
import { computeCorpusFingerprint, getCalibrationRun, openCalibrateDb } from "./calibrate";
|
|
3
3
|
import { createClients, RemoteInferenceError } from "./clients";
|
|
4
4
|
import { embeddingDimension, embeddingFingerprint, expandHome, rerankerFingerprint } from "./config";
|
|
5
|
+
import { describeDeployment, type DeploymentIdentity } from "./deployment";
|
|
5
6
|
import { openIndex } from "./db";
|
|
6
7
|
import { parseManifest, resolveManifestPath, validateManifest } from "./manifest";
|
|
7
8
|
import { readSkillmuxMarker } from "./sync";
|
|
@@ -18,6 +19,16 @@ export interface DoctorCheck {
|
|
|
18
19
|
export interface DoctorReport {
|
|
19
20
|
mode: Config["inference"]["mode"];
|
|
20
21
|
capability: "hybrid" | "lexical-only" | "unavailable";
|
|
22
|
+
retrieval_capability: "lexical" | "hybrid" | "reranked";
|
|
23
|
+
version: DeploymentIdentity["version"];
|
|
24
|
+
runtime: DeploymentIdentity["runtime"];
|
|
25
|
+
image_variant: DeploymentIdentity["image_variant"];
|
|
26
|
+
vault_path: DeploymentIdentity["vault_path"];
|
|
27
|
+
state_dir: DeploymentIdentity["state_dir"];
|
|
28
|
+
inference_mode: DeploymentIdentity["inference_mode"];
|
|
29
|
+
local_embedding_bundle: DeploymentIdentity["local_embedding_bundle"];
|
|
30
|
+
remote_embedding_configured: DeploymentIdentity["remote_embedding_configured"];
|
|
31
|
+
remote_reranker_configured: DeploymentIdentity["remote_reranker_configured"];
|
|
21
32
|
checks: DoctorCheck[];
|
|
22
33
|
}
|
|
23
34
|
|
|
@@ -93,7 +104,12 @@ function checkCalibration(config: Config): DoctorCheck {
|
|
|
93
104
|
return { name: "calibration", ok: true, detail: `thresholds from applied calibration run "${runId}"` };
|
|
94
105
|
}
|
|
95
106
|
|
|
96
|
-
export
|
|
107
|
+
export { describeDeployment };
|
|
108
|
+
|
|
109
|
+
export async function diagnose(
|
|
110
|
+
config: Config,
|
|
111
|
+
environment: Record<string, string | undefined> = process.env,
|
|
112
|
+
): Promise<DoctorReport> {
|
|
97
113
|
const checks: DoctorCheck[] = [];
|
|
98
114
|
checks.push({ name: "vault", ok: existsSync(expandHome(config.vault_path)), detail: expandHome(config.vault_path) });
|
|
99
115
|
|
|
@@ -179,27 +195,37 @@ export async function diagnose(config: Config): Promise<DoctorReport> {
|
|
|
179
195
|
? { detail: error.message, failure_kind: error.kind }
|
|
180
196
|
: { detail: "unexpected inference failure", failure_kind: "unexpected" };
|
|
181
197
|
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const actualDimension = vectors[0]?.length ?? 0;
|
|
198
|
+
const deployment = describeDeployment(config, environment);
|
|
199
|
+
const lexicalOnlySlim = deployment.image_variant === "slim" && config.inference.mode === "local";
|
|
200
|
+
if (lexicalOnlySlim) {
|
|
186
201
|
checks.push({
|
|
187
|
-
name: "
|
|
188
|
-
ok:
|
|
189
|
-
detail:
|
|
202
|
+
name: "retrieval",
|
|
203
|
+
ok: true,
|
|
204
|
+
detail: "lexical retrieval; Configure remote embeddings for hybrid retrieval",
|
|
190
205
|
});
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (clients.rerank) {
|
|
206
|
+
} else {
|
|
207
|
+
const clients = createClients(config);
|
|
196
208
|
try {
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
209
|
+
const vectors = await clients.embed(["skill router diagnostic"]);
|
|
210
|
+
const actualDimension = vectors[0]?.length ?? 0;
|
|
211
|
+
checks.push({
|
|
212
|
+
name: "embedding",
|
|
213
|
+
ok: actualDimension === embeddingDimension(config),
|
|
214
|
+
detail: `dimension ${actualDimension}`,
|
|
215
|
+
});
|
|
201
216
|
} catch (error) {
|
|
202
|
-
checks.push({ name: "
|
|
217
|
+
checks.push({ name: "embedding", ok: false, ...inferenceFailure(error) });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (clients.rerank) {
|
|
221
|
+
try {
|
|
222
|
+
const scores = await clients.rerank("skill router diagnostic", [
|
|
223
|
+
{ skill_id: "doctor", text: "Routes a task to an appropriate skill." },
|
|
224
|
+
]);
|
|
225
|
+
checks.push({ name: "reranker", ok: scores.length === 1 && Number.isFinite(scores[0]), detail: "one finite score" });
|
|
226
|
+
} catch (error) {
|
|
227
|
+
checks.push({ name: "reranker", ok: false, ...inferenceFailure(error) });
|
|
228
|
+
}
|
|
203
229
|
}
|
|
204
230
|
}
|
|
205
231
|
|
|
@@ -208,11 +234,14 @@ export async function diagnose(config: Config): Promise<DoctorReport> {
|
|
|
208
234
|
}
|
|
209
235
|
|
|
210
236
|
const inferenceReady = checks.some((check) => check.name === "embedding" && check.ok);
|
|
237
|
+
const rerankerReady = checks.some((check) => check.name === "reranker" && check.ok);
|
|
211
238
|
const coreReady = checks.some((check) => check.name === "vault" && check.ok)
|
|
212
239
|
&& checks.some((check) => check.name === "state" && check.ok);
|
|
213
240
|
return {
|
|
241
|
+
...deployment,
|
|
214
242
|
mode: config.inference.mode,
|
|
215
243
|
capability: !coreReady ? "unavailable" : inferenceReady ? "hybrid" : "lexical-only",
|
|
244
|
+
retrieval_capability: rerankerReady ? "reranked" : inferenceReady ? "hybrid" : "lexical",
|
|
216
245
|
checks,
|
|
217
246
|
};
|
|
218
247
|
}
|
package/src/metrics.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import type { ReadinessSnapshot } from "./readiness";
|
|
2
|
+
import type { DeploymentIdentity } from "./deployment";
|
|
3
|
+
|
|
4
|
+
type MetricsDeploymentIdentity = Pick<
|
|
5
|
+
DeploymentIdentity,
|
|
6
|
+
"version" | "runtime" | "image_variant"
|
|
7
|
+
>;
|
|
2
8
|
|
|
3
9
|
export class MetricsRegistry {
|
|
4
10
|
private requests = new Map<string, number>();
|
|
@@ -13,11 +19,16 @@ export class MetricsRegistry {
|
|
|
13
19
|
private errors = 0;
|
|
14
20
|
private rateLimitsExceeded = 0;
|
|
15
21
|
private readiness: ReadinessSnapshot | null = null;
|
|
22
|
+
private deployment: MetricsDeploymentIdentity | null = null;
|
|
16
23
|
|
|
17
24
|
setReadiness(readiness: ReadinessSnapshot) {
|
|
18
25
|
this.readiness = readiness;
|
|
19
26
|
}
|
|
20
27
|
|
|
28
|
+
setDeployment(deployment: MetricsDeploymentIdentity) {
|
|
29
|
+
this.deployment = deployment;
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
recordRequest(method: string) {
|
|
22
33
|
this.requests.set(method, (this.requests.get(method) || 0) + 1);
|
|
23
34
|
}
|
|
@@ -96,6 +107,13 @@ export class MetricsRegistry {
|
|
|
96
107
|
lines.push(`skill_router_retrieval_capability{capability="${capability}"} ${this.readiness?.retrieval === capability ? 1 : 0}`);
|
|
97
108
|
}
|
|
98
109
|
|
|
110
|
+
if (this.deployment) {
|
|
111
|
+
const imageVariant = this.deployment.image_variant ?? "none";
|
|
112
|
+
lines.push("# HELP skill_router_deployment_info Immutable deployment identity for operator comparison.");
|
|
113
|
+
lines.push("# TYPE skill_router_deployment_info gauge");
|
|
114
|
+
lines.push(`skill_router_deployment_info{version="${this.deployment.version}",runtime="${this.deployment.runtime}",image_variant="${imageVariant}"} 1`);
|
|
115
|
+
}
|
|
116
|
+
|
|
99
117
|
return lines.join("\n") + "\n";
|
|
100
118
|
}
|
|
101
119
|
}
|
package/src/output.ts
CHANGED
|
@@ -38,11 +38,15 @@ export function formatJsonEnvelope<T>(opts: {
|
|
|
38
38
|
|
|
39
39
|
export class CliError extends Error {
|
|
40
40
|
exitCode: number;
|
|
41
|
+
code: string;
|
|
42
|
+
details?: unknown;
|
|
41
43
|
|
|
42
|
-
constructor(message: string, exitCode: number) {
|
|
44
|
+
constructor(message: string, exitCode: number, code = `EXIT_${exitCode}`, details?: unknown) {
|
|
43
45
|
super(message);
|
|
44
46
|
this.name = "CliError";
|
|
45
47
|
this.exitCode = exitCode;
|
|
48
|
+
this.code = code;
|
|
49
|
+
this.details = details;
|
|
46
50
|
}
|
|
47
51
|
}
|
|
48
52
|
|
package/src/router-core.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite";
|
|
2
|
-
import { watch } from "node:fs";
|
|
2
|
+
import { existsSync, watch } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { buildAuditRow } from "./audit";
|
|
5
5
|
import { embeddingDimension, embeddingFingerprint, expandHome, loadConfig } from "./config";
|
|
@@ -334,6 +334,11 @@ export async function startVaultWatcher(): Promise<() => void> {
|
|
|
334
334
|
const vaultPath = expandHome(config.vault_path);
|
|
335
335
|
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
336
336
|
|
|
337
|
+
// A fresh installation may not have a vault checkout yet. Serving an empty
|
|
338
|
+
// vault is valid; leave live indexing inactive until the next server start
|
|
339
|
+
// finds a checkout rather than failing startup with ENOENT.
|
|
340
|
+
if (!existsSync(vaultPath)) return () => {};
|
|
341
|
+
|
|
337
342
|
const watcher = watch(vaultPath, { recursive: true }, (_event, filename) => {
|
|
338
343
|
const skillId = filename?.split(/[\\/]/)[0];
|
|
339
344
|
if (!skillId || !SKILL_ID_PATTERN.test(skillId)) return;
|
package/src/server.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { createClients } from "./clients";
|
|
7
7
|
import { loadConfig, resolveConfigPath } from "./config";
|
|
8
|
+
import { describeDeployment } from "./deployment";
|
|
8
9
|
import { ConfigWatcher, type ReloadStatus } from "./config-watcher";
|
|
9
10
|
import { RuntimeSnapshotManager } from "./snapshot";
|
|
10
11
|
import {
|
|
@@ -147,6 +148,7 @@ export async function startServer(opts?: {
|
|
|
147
148
|
restart_required_keys: [],
|
|
148
149
|
};
|
|
149
150
|
configure({ config, clients: initialClients });
|
|
151
|
+
metricsRegistry.setDeployment(describeDeployment(config));
|
|
150
152
|
// An injected config has no guaranteed file source. Watch it only when the
|
|
151
153
|
// caller explicitly supplies that source; normal server startup always watches.
|
|
152
154
|
const watcherPath =
|
|
@@ -276,10 +278,16 @@ export async function startServer(opts?: {
|
|
|
276
278
|
}
|
|
277
279
|
if (url.pathname === "/health/ready") {
|
|
278
280
|
const readiness = readinessState.get();
|
|
281
|
+
const deployment = describeDeployment(config);
|
|
279
282
|
const headers = new Headers({ "Content-Type": "application/json" });
|
|
280
283
|
if (allowOriginHeader)
|
|
281
284
|
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
282
|
-
return new Response(JSON.stringify(
|
|
285
|
+
return new Response(JSON.stringify({
|
|
286
|
+
...readiness,
|
|
287
|
+
version: deployment.version,
|
|
288
|
+
runtime: deployment.runtime,
|
|
289
|
+
image_variant: deployment.image_variant,
|
|
290
|
+
}), {
|
|
283
291
|
status: readiness.status === "ready" ? 200 : 503,
|
|
284
292
|
headers,
|
|
285
293
|
});
|
|
@@ -395,6 +403,7 @@ export async function startServer(opts?: {
|
|
|
395
403
|
|
|
396
404
|
if (req.method === "GET" && url.pathname === "/admin/v1/config") {
|
|
397
405
|
const { effective, sources } = await getEffectiveConfig(configPath);
|
|
406
|
+
const deployment = describeDeployment(config);
|
|
398
407
|
const desiredHash = computeHash(effective);
|
|
399
408
|
const snapshot = snapshots.acquire();
|
|
400
409
|
const activeRevision = computeHash(snapshot.snapshot.config);
|
|
@@ -417,6 +426,9 @@ export async function startServer(opts?: {
|
|
|
417
426
|
...status,
|
|
418
427
|
readiness: readinessState.get(),
|
|
419
428
|
runtime: "running",
|
|
429
|
+
version: deployment.version,
|
|
430
|
+
deployment_runtime: deployment.runtime,
|
|
431
|
+
image_variant: deployment.image_variant,
|
|
420
432
|
},
|
|
421
433
|
}),
|
|
422
434
|
{ status: 200, headers },
|
|
@@ -436,7 +448,7 @@ export async function startServer(opts?: {
|
|
|
436
448
|
|
|
437
449
|
const ifMatch = req.headers.get("if-match") || "";
|
|
438
450
|
const cleanIfMatch = ifMatch.replace(/^"|"$/g, "");
|
|
439
|
-
const { effective } = await getEffectiveConfig();
|
|
451
|
+
const { effective } = await getEffectiveConfig(configPath);
|
|
440
452
|
const currentHash = computeHash(effective);
|
|
441
453
|
|
|
442
454
|
if (!ifMatch || cleanIfMatch !== currentHash) {
|