@klhapp/skillmux 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.
@@ -0,0 +1,100 @@
1
+ # Configuration
2
+
3
+ Skillmux defaults to FTS5 plus local GTE-small semantic retrieval. Most users need no config file.
4
+
5
+ ## Local mode
6
+
7
+ ```toml
8
+ [inference]
9
+ mode = "local"
10
+ ```
11
+
12
+ The versioned `gte-small-v1` bundle uses normalized, mean-pooled `Xenova/gte-small` embeddings (384 dimensions), quantized to q8 on CPU. Models are cached in `~/.cache/skillmux/models`. FTS5 and cosine result lists are combined with reciprocal-rank fusion; without a reranker the calling LLM selects from the ordered shortlist.
13
+
14
+ Advanced local overrides:
15
+
16
+ ```toml
17
+ [inference]
18
+ mode = "local"
19
+ models_dir = "~/.cache/skillmux/models"
20
+
21
+ [inference.embedding]
22
+ model = "Xenova/gte-small"
23
+ dimension = 384
24
+ device = "cpu"
25
+ dtype = "q8"
26
+
27
+ ```
28
+
29
+ Use `skillmux models download` to prefetch models and `skillmux doctor` to verify readiness.
30
+
31
+ ## Remote mode
32
+
33
+ See [`config.remote.example.toml`](../config.remote.example.toml). Embeddings must implement the OpenAI-compatible `POST /v1/embeddings` API. Optional reranking must implement Infinity's `POST /rerank` API. Credentials are read from the environment variables named by `api_key_env`.
34
+
35
+ Remote embedding `dimension` is required. Changing the provider, model, or dimension invalidates stored vectors and safely rebuilds them.
36
+
37
+ ## Advanced retrieval
38
+
39
+ Candidate-generation depth is configurable but normally does not need tuning:
40
+
41
+ ```toml
42
+ [recall]
43
+ k_lexical = 20
44
+ k_vector = 20
45
+
46
+ [thresholds]
47
+ candidate_limit = 5
48
+ ```
49
+
50
+ The router considers up to 20 candidates from each retrieval lane but returns at most 5 to the calling LLM.
51
+
52
+ Reranker thresholds have no universal default because score distributions are model-specific. When configuring a reranker, provide calibrated `inference.thresholds.match_score`, `inference.thresholds.match_margin`, and `inference.thresholds.candidate_floor`; otherwise configuration is rejected rather than silently applying unsuitable values.
53
+
54
+ ## HTTP server
55
+
56
+ ```toml
57
+ [server]
58
+ hostname = "127.0.0.1"
59
+ auth_enabled = false
60
+ auth_token_env = "SKILLMUX_AUTH_TOKEN"
61
+ allowed_origins = []
62
+
63
+ [server.rate_limit]
64
+ enabled = false
65
+ requests_per_minute = 60
66
+ trust_proxy = false
67
+ ```
68
+
69
+ Defaults are loopback-only (`hostname = "127.0.0.1"`) with CORS deny-by-default (`allowed_origins = []`) — a zero-config `skillmux serve --transport http` is not reachable from the network or from a browser tab on another origin. Docker sets `hostname` to `0.0.0.0` automatically (`RUNNING_IN_DOCKER=true`) since port-mapping needs the container to accept connections on all interfaces.
70
+
71
+ Before exposing HTTP beyond localhost, set `hostname` to a reachable interface, `auth_enabled = true` with a token, and populate `allowed_origins` with the specific origins that need browser access. `rate_limit.trust_proxy` should stay `false` unless a trusted reverse proxy sets `X-Forwarded-For` — it's otherwise a client-controlled, spoofable header and trusting it defeats per-client rate limiting.
72
+
73
+ ## Tiers and the manifest
74
+
75
+ `skillmux init`/`sync` manage an optional second delivery path — pinning a subset of skills as real symlinks inside an agent's own skill directory, instead of routing every request through `resolve_skill`. See the README's [Tiers](../README.md#tiers-routed-vs-pinned) section for the concept and a walkthrough; this is the manifest reference.
76
+
77
+ ### `skillmux.toml`
78
+
79
+ Lives at the vault root (a legacy `skr.toml` is still read if present, never written):
80
+
81
+ ```toml
82
+ [core]
83
+ skills = ["csv-formatter"] # pinned into every [targets.*] dir; capped at 25
84
+
85
+ [project.repo1]
86
+ repos = ["/Users/you/code/repo1"] # only synced for repos paths that exist locally
87
+ skills = ["pdf-extractor"] # must not overlap [core]
88
+
89
+ [targets.claude]
90
+ dir = "/Users/you/.claude/skills"
91
+ project = false # true = also apply [project.*] groups scoped to this target
92
+ ```
93
+
94
+ - `[core].skills` — symlinked into every `[targets.*]` dir on `sync`. Capped at 25 skills; `sync` fails if a listed skill id isn't actually in the vault.
95
+ - `[project.<group>].skills` — symlinked only into `<repo>/<relative path from $HOME to the target dir>`, for each `repos` entry, and only for targets with `project = true`. `repos` entries must resolve under `$HOME` (that's how the pin path is derived). A skill can't appear in both `[core]` and the same `[project.*]` group.
96
+ - `[targets.<name>]` — one entry per adopted surface. `skillmux init --target <name> --yes` writes these; hand-editing is fine as long as `sync` is still allowed to own the directory (see below).
97
+
98
+ ### Ownership marker
99
+
100
+ Every directory `sync` manages gets a `.skillmux` marker file (`{"managed_by": "skillmux", "target": "<name>", "created_at": ...}`). `sync` refuses to touch a directory that exists but has no marker — run `skillmux init --target <name> --yes` first, which either creates the directory fresh or adopts an existing one in place (contents untouched). This is also why `sync --restore-monolith` (which deletes the marker and replaces the whole directory with one symlink straight to the vault) requires re-running `init` before that target can be `sync`'d again.
@@ -0,0 +1,62 @@
1
+ # Releasing
2
+
3
+ Releases are created from version tags after `main` is green.
4
+
5
+ ## Prepare
6
+
7
+ 1. Update `version` in `package.json`.
8
+ 2. Move relevant entries from `Unreleased` into a dated version section in `CHANGELOG.md`.
9
+ 3. Merge those changes through a pull request and confirm CI passes.
10
+
11
+ ## Publish
12
+
13
+ Create and push the matching tag:
14
+
15
+ ```bash
16
+ git switch main
17
+ git pull --ff-only
18
+ git tag v0.1.1
19
+ git push origin v0.1.1
20
+ ```
21
+
22
+ The tag must exactly match `v` plus the `package.json` version. A mismatch stops the release.
23
+
24
+ The release workflow publishes:
25
+
26
+ - `skillmux-linux-amd64`
27
+ - `skillmux-linux-arm64`
28
+ - `SHA256SUMS`
29
+ - GitHub build provenance attestations when the repository is public
30
+ - Full image: `:<version>`, `:<major>.<minor>`, `:<major>`, and `:latest`
31
+ - Slim image: `:<version>-slim`, `:<major>.<minor>-slim`, `:<major>-slim`, and `:latest-slim`
32
+ - Multi-architecture `linux/amd64` and `linux/arm64` images with SBOM and provenance
33
+
34
+ Each Docker tag is a multi-architecture manifest. Users run the same tag on AMD64 and ARM64; Docker automatically pulls the matching image.
35
+
36
+ Container images are published to GitHub Container Registry only; the release workflow does not require external registry credentials.
37
+ Private repositories still publish BuildKit SBOM/provenance with container images, but GitHub artifact attestations are skipped because GitHub does not support them for user-owned private repositories.
38
+
39
+ Verify downloaded binaries with:
40
+
41
+ ```bash
42
+ sha256sum --check SHA256SUMS
43
+ ./skillmux-linux-amd64 config show
44
+ ```
45
+
46
+ Verify release assets from GitHub without cloning the repository:
47
+
48
+ ```bash
49
+ gh release download v0.1.1 --repo klhq/skillmux
50
+ sha256sum --check SHA256SUMS
51
+ ```
52
+
53
+ Verify the container with a read-only vault mount:
54
+
55
+ ```bash
56
+ docker run --rm \
57
+ -v ~/.agents/skills:/vault:ro \
58
+ -p 3000:3000 \
59
+ ghcr.io/klhq/skillmux:latest
60
+
61
+ curl --fail http://127.0.0.1:3000/health/ready
62
+ ```
@@ -0,0 +1,314 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://klh.app/schemas/skillmux/router-core.json",
4
+ "title": "Skillmux — router-core contract",
5
+ "description": "Typed contract for the skillmux MCP server: tool inputs/outputs, config.toml, and audit-log rows. The SKILL.md body must appear exactly once on the wire.",
6
+ "$defs": {
7
+ "SkillId": {
8
+ "type": "string",
9
+ "description": "Vault directory name of the skill (~/.agents/skills/<skill_id>/SKILL.md).",
10
+ "pattern": "^[a-z0-9][a-z0-9-]{1,127}$"
11
+ },
12
+ "Sha256Hex": {
13
+ "type": "string",
14
+ "description": "Lowercase hex SHA-256 of the exact SKILL.md bytes.",
15
+ "pattern": "^[a-f0-9]{64}$"
16
+ },
17
+ "RelativePath": {
18
+ "type": "string",
19
+ "description": "Path of a supporting file relative to the skill directory (e.g. 'references/palette.md'). Never absolute, never contains '..'.",
20
+ "pattern": "^(?!/)(?!.*\\.\\.).+$",
21
+ "minLength": 1,
22
+ "maxLength": 512
23
+ },
24
+ "Candidate": {
25
+ "type": "object",
26
+ "description": "One shortlist entry in an ambiguous result.",
27
+ "properties": {
28
+ "skill_id": { "$ref": "#/$defs/SkillId" },
29
+ "title": { "type": "string", "minLength": 1, "maxLength": 512 },
30
+ "description": { "type": "string", "minLength": 1 }
31
+ },
32
+ "required": ["skill_id", "title", "description"],
33
+ "additionalProperties": false
34
+ },
35
+ "ResolveSkillInput": {
36
+ "type": "object",
37
+ "properties": {
38
+ "query": {
39
+ "type": "string",
40
+ "description": "Natural-language task description to route. Normalized (NFC) by the server; never semantically rewritten.",
41
+ "minLength": 1,
42
+ "maxLength": 8192
43
+ }
44
+ },
45
+ "required": ["query"],
46
+ "additionalProperties": false
47
+ },
48
+ "MatchedResult": {
49
+ "type": "object",
50
+ "description": "One reranked candidate clearly dominates. Full SKILL.md is delivered inline.",
51
+ "properties": {
52
+ "outcome": { "const": "matched" },
53
+ "retrieval": { "enum": ["exact", "reranked"] },
54
+ "skill_id": { "$ref": "#/$defs/SkillId" },
55
+ "title": { "type": "string", "minLength": 1, "maxLength": 512 },
56
+ "content_sha256": { "$ref": "#/$defs/Sha256Hex" },
57
+ "score": {
58
+ "type": "number",
59
+ "description": "Top configured-reranker score. Satisfies score >= config thresholds.match_score."
60
+ },
61
+ "margin": {
62
+ "type": "number",
63
+ "description": "Top minus second configured-reranker score. Equals score when there is exactly one candidate.",
64
+ "minimum": 0
65
+ },
66
+ "body": {
67
+ "type": "string",
68
+ "description": "Verbatim UTF-8 content of SKILL.md. sha256(body bytes) == content_sha256 == hash of the on-disk file read at delivery time.",
69
+ "minLength": 1
70
+ },
71
+ "files": {
72
+ "type": "array",
73
+ "description": "Relative paths of the skill's supporting files (contents NOT included). Same semantics as FetchSkillResult.files.",
74
+ "items": { "$ref": "#/$defs/RelativePath" },
75
+ "uniqueItems": true
76
+ }
77
+ },
78
+ "required": ["outcome", "retrieval", "skill_id", "title", "content_sha256", "score", "margin", "body", "files"],
79
+ "additionalProperties": false
80
+ },
81
+ "AmbiguousResult": {
82
+ "type": "object",
83
+ "description": "Several plausible candidates; the calling LLM picks, then calls fetch_skill.",
84
+ "properties": {
85
+ "outcome": { "const": "ambiguous" },
86
+ "retrieval": { "enum": ["exact", "reranked", "hybrid", "lexical"] },
87
+ "candidates": {
88
+ "type": "array",
89
+ "items": { "$ref": "#/$defs/Candidate" },
90
+ "minItems": 1,
91
+ "maxItems": 5
92
+ }
93
+ },
94
+ "required": ["outcome", "retrieval", "candidates"],
95
+ "additionalProperties": false
96
+ },
97
+ "NoMatchResult": {
98
+ "type": "object",
99
+ "description": "AC4: no skill passed the relevance floor. Caller proceeds under its normal workflow; must not load an unrelated skill.",
100
+ "properties": {
101
+ "outcome": { "const": "no_match" },
102
+ "retrieval": { "enum": ["exact", "reranked", "hybrid", "lexical"] },
103
+ "message": {
104
+ "type": "string",
105
+ "description": "Fixed guidance string telling the caller to proceed normally.",
106
+ "minLength": 1
107
+ }
108
+ },
109
+ "required": ["outcome", "retrieval", "message"],
110
+ "additionalProperties": false
111
+ },
112
+ "ResolveSkillResult": {
113
+ "description": "AC1: exactly three outcomes, discriminated by 'outcome'.",
114
+ "oneOf": [
115
+ { "$ref": "#/$defs/MatchedResult" },
116
+ { "$ref": "#/$defs/AmbiguousResult" },
117
+ { "$ref": "#/$defs/NoMatchResult" }
118
+ ]
119
+ },
120
+ "FetchSkillInput": {
121
+ "type": "object",
122
+ "properties": {
123
+ "skill_id": { "$ref": "#/$defs/SkillId" }
124
+ },
125
+ "required": ["skill_id"],
126
+ "additionalProperties": false
127
+ },
128
+ "FetchSkillResult": {
129
+ "type": "object",
130
+ "description": "AC5: verbatim delivery independent of any prior resolve outcome. Unknown skill_id raises ToolError with code SKILL_NOT_FOUND instead of returning this shape.",
131
+ "properties": {
132
+ "skill_id": { "$ref": "#/$defs/SkillId" },
133
+ "title": { "type": "string", "minLength": 1, "maxLength": 512 },
134
+ "content_sha256": { "$ref": "#/$defs/Sha256Hex" },
135
+ "body": { "type": "string", "minLength": 1 },
136
+ "files": {
137
+ "type": "array",
138
+ "description": "Relative paths of supporting files under the skill directory (references/, scripts/, ...). Paths only; callers with filesystem access read contents directly.",
139
+ "items": { "$ref": "#/$defs/RelativePath" },
140
+ "uniqueItems": true
141
+ }
142
+ },
143
+ "required": ["skill_id", "title", "content_sha256", "body", "files"],
144
+ "additionalProperties": false
145
+ },
146
+ "ErrorCode": {
147
+ "type": "string",
148
+ "description": "Machine-readable code carried in MCP ToolError messages as 'CODE: human message'.",
149
+ "enum": ["SKILL_NOT_FOUND", "VAULT_UNAVAILABLE", "INTERNAL"]
150
+ },
151
+ "Config": {
152
+ "type": "object",
153
+ "description": "config.toml, validated at startup. Local ONNX is the default; remote inference is explicit.",
154
+ "properties": {
155
+ "vault_path": {
156
+ "type": "string",
157
+ "description": "Skill vault root. Default: ~/.agents/skills",
158
+ "default": "~/.agents/skills"
159
+ },
160
+ "state_dir": {
161
+ "type": "string",
162
+ "description": "All router writes are confined here (AC9): index SQLite (incl. audit log), vector matrix.",
163
+ "default": "~/.local/state/skillmux"
164
+ },
165
+ "recall": {
166
+ "type": "object",
167
+ "properties": {
168
+ "k_lexical": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 },
169
+ "k_vector": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }
170
+ },
171
+ "required": ["k_lexical", "k_vector"],
172
+ "additionalProperties": false
173
+ },
174
+ "thresholds": {
175
+ "type": "object",
176
+ "description": "Caller-facing shortlist policy. Reranker thresholds live under inference.thresholds.",
177
+ "properties": {
178
+ "candidate_limit": {
179
+ "type": "integer",
180
+ "minimum": 1,
181
+ "maximum": 50,
182
+ "default": 5,
183
+ "description": "Maximum number of candidates returned on ambiguous outcomes (top-k limit)."
184
+ }
185
+ },
186
+ "required": ["candidate_limit"],
187
+ "additionalProperties": false
188
+ },
189
+ "inference": { "$ref": "#/$defs/InferenceConfig" },
190
+ "server": { "$ref": "#/$defs/ServerConfig" }
191
+ },
192
+ "required": ["vault_path", "state_dir", "recall", "thresholds", "inference"],
193
+ "additionalProperties": false
194
+ },
195
+ "InferenceConfig": {
196
+ "oneOf": [
197
+ {
198
+ "type": "object",
199
+ "properties": {
200
+ "mode": { "const": "local" },
201
+ "bundle": { "const": "gte-small-v1" },
202
+ "models_dir": { "type": "string", "default": "~/.cache/skillmux/models" },
203
+ "embedding": {
204
+ "type": "object",
205
+ "properties": {
206
+ "model": { "const": "Xenova/gte-small" },
207
+ "dimension": { "const": 384 },
208
+ "device": { "type": "string" },
209
+ "dtype": { "type": "string" }
210
+ },
211
+ "required": ["model", "dimension"],
212
+ "additionalProperties": false
213
+ }
214
+ },
215
+ "required": ["mode", "bundle", "models_dir", "embedding"],
216
+ "additionalProperties": false
217
+ },
218
+ {
219
+ "type": "object",
220
+ "properties": {
221
+ "mode": { "const": "remote" },
222
+ "timeout_ms": { "type": "integer", "minimum": 100, "maximum": 30000, "default": 2000 },
223
+ "embedding": {
224
+ "type": "object",
225
+ "properties": {
226
+ "provider": { "const": "openai" },
227
+ "base_url": { "type": "string", "format": "uri" },
228
+ "model": { "type": "string", "minLength": 1 },
229
+ "dimension": { "type": "integer", "minimum": 1 },
230
+ "api_key_env": { "type": "string", "minLength": 1 }
231
+ },
232
+ "required": ["provider", "base_url", "model", "dimension"],
233
+ "additionalProperties": false
234
+ },
235
+ "reranker": {
236
+ "type": "object",
237
+ "properties": {
238
+ "provider": { "const": "infinity" },
239
+ "base_url": { "type": "string", "format": "uri" },
240
+ "model": { "type": "string", "minLength": 1 },
241
+ "api_key_env": { "type": "string", "minLength": 1 }
242
+ },
243
+ "required": ["provider", "base_url", "model"],
244
+ "additionalProperties": false
245
+ },
246
+ "thresholds": {
247
+ "type": "object",
248
+ "description": "Model-specific calibrated values; required when reranker is configured.",
249
+ "properties": {
250
+ "match_score": { "type": "number" },
251
+ "match_margin": { "type": "number", "minimum": 0 },
252
+ "candidate_floor": { "type": "number" }
253
+ },
254
+ "required": ["match_score", "match_margin", "candidate_floor"],
255
+ "additionalProperties": false
256
+ }
257
+ },
258
+ "required": ["mode", "timeout_ms", "embedding"],
259
+ "additionalProperties": false
260
+ }
261
+ ]
262
+ },
263
+ "ServerConfig": {
264
+ "type": "object",
265
+ "properties": {
266
+ "auth_enabled": { "type": "boolean", "default": false },
267
+ "auth_token_env": { "type": "string", "default": "SKILLMUX_AUTH_TOKEN" },
268
+ "allowed_origins": { "type": "array", "items": { "type": "string" }, "default": ["*"] },
269
+ "rate_limit": {
270
+ "type": "object",
271
+ "properties": {
272
+ "enabled": { "type": "boolean", "default": false },
273
+ "requests_per_minute": { "type": "integer", "minimum": 1, "default": 60 }
274
+ },
275
+ "required": ["enabled", "requests_per_minute"],
276
+ "additionalProperties": false
277
+ }
278
+ },
279
+ "required": ["auth_enabled", "auth_token_env", "allowed_origins"],
280
+ "additionalProperties": false
281
+ },
282
+ "AuditRow": {
283
+ "type": "object",
284
+ "description": "AC10: one row per resolve_skill call, appended to the audit table in the state-dir SQLite. Raw query stored deliberately (single-user private homelab).",
285
+ "properties": {
286
+ "id": { "type": "integer", "minimum": 1 },
287
+ "ts": { "type": "string", "format": "date-time" },
288
+ "query": { "type": "string" },
289
+ "outcome": { "type": "string", "enum": ["matched", "ambiguous", "no_match"] },
290
+ "retrieval": { "enum": ["exact", "reranked", "hybrid", "lexical"] },
291
+ "candidates": {
292
+ "type": "array",
293
+ "description": "skill_id + score of every reranked (or FTS5-ranked) candidate considered, best first.",
294
+ "items": {
295
+ "type": "object",
296
+ "properties": {
297
+ "skill_id": { "$ref": "#/$defs/SkillId" },
298
+ "score": { "type": ["number", "null"] }
299
+ },
300
+ "required": ["skill_id", "score"],
301
+ "additionalProperties": false
302
+ }
303
+ },
304
+ "selected_skill_id": {
305
+ "oneOf": [{ "$ref": "#/$defs/SkillId" }, { "type": "null" }],
306
+ "description": "Set iff outcome=matched."
307
+ },
308
+ "latency_ms": { "type": "integer", "minimum": 0 }
309
+ },
310
+ "required": ["id", "ts", "query", "outcome", "retrieval", "candidates", "selected_skill_id", "latency_ms"],
311
+ "additionalProperties": false
312
+ }
313
+ }
314
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@klhapp/skillmux",
3
+ "version": "0.2.0",
4
+ "description": "Local read-only MCP server routing natural-language task queries to skills in a SKILL.md vault, with zero-loss delivery",
5
+ "type": "module",
6
+ "private": false,
7
+ "author": "Lance Hsu <lance@klh.app>",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/klhq/skillmux.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/klhq/skillmux/issues"
15
+ },
16
+ "homepage": "https://github.com/klhq/skillmux#readme",
17
+ "bin": {
18
+ "skillmux": "./src/cli.ts"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "docs/schema.json",
26
+ "docs/configuration.md",
27
+ "docs/releasing.md",
28
+ "README.md",
29
+ "LICENSE",
30
+ "CHANGELOG.md",
31
+ "config.example.toml",
32
+ "config.remote.example.toml"
33
+ ],
34
+ "scripts": {
35
+ "test": "bun test",
36
+ "serve": "bun run src/cli.ts serve",
37
+ "index": "bun run src/cli.ts index",
38
+ "eval": "bun run src/cli.ts eval",
39
+ "doctor": "bun run src/cli.ts doctor",
40
+ "models:download": "bun run src/cli.ts models download",
41
+ "build": "bun build --compile src/cli.ts --outfile dist/skillmux"
42
+ },
43
+ "devDependencies": {
44
+ "bun-types": "^1.3.14"
45
+ },
46
+ "dependencies": {
47
+ "@huggingface/transformers": "^4.2.0",
48
+ "@modelcontextprotocol/sdk": "^1.29.0",
49
+ "zod": "^4.4.3"
50
+ },
51
+ "trustedDependencies": [
52
+ "onnxruntime-node",
53
+ "protobufjs"
54
+ ]
55
+ }
package/src/audit.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { AuditRow } from "./types";
2
+
3
+ /** Shape an audit row to exactly the schema's AuditRow fields — nothing extra survives. */
4
+ export function buildAuditRow(row: AuditRow): AuditRow {
5
+ return {
6
+ id: row.id,
7
+ ts: row.ts,
8
+ query: row.query,
9
+ outcome: row.outcome,
10
+ retrieval: row.retrieval,
11
+ candidates: row.candidates.map((c) => ({ skill_id: c.skill_id, score: c.score })),
12
+ selected_skill_id: row.selected_skill_id,
13
+ latency_ms: row.latency_ms,
14
+ };
15
+ }