@klhapp/skillmux 1.1.0 → 1.3.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/CHANGELOG.md +22 -0
- package/README.md +155 -516
- package/config.example.toml +5 -4
- package/config.remote.example.toml +8 -3
- package/docs/README.md +52 -0
- package/docs/assets/architecture.svg +156 -0
- package/docs/assets/logo.png +0 -0
- package/docs/calibration.md +111 -0
- package/docs/cli.md +350 -0
- package/docs/concepts.md +165 -0
- package/docs/configuration.md +41 -8
- package/docs/deployment.md +250 -0
- package/docs/getting-started.md +239 -0
- package/docs/mcp-routing.md +172 -0
- package/docs/releasing.md +4 -3
- package/docs/skill-management.md +209 -0
- package/docs/troubleshooting.md +199 -0
- package/package.json +3 -5
- package/src/adapters.ts +33 -40
- package/src/calibrate.ts +165 -9
- package/src/cli.ts +51 -6
- package/src/config-watcher.ts +5 -1
- package/src/dataset-generator.ts +75 -96
- package/src/server.ts +10 -70
package/src/calibrate.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { Database } from "bun:sqlite";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { decideResolveOutcome } from "./decision";
|
|
6
|
-
import type { RankedCandidate } from "./types";
|
|
6
|
+
import type { AuditRow, RankedCandidate } from "./types";
|
|
7
7
|
|
|
8
8
|
export { generateDataset, type GenerateDatasetOptions } from "./dataset-generator";
|
|
9
9
|
|
|
@@ -15,11 +15,21 @@ export { generateDataset, type GenerateDatasetOptions } from "./dataset-generato
|
|
|
15
15
|
export type DecisionSplit = "tune" | "test";
|
|
16
16
|
export type DecisionOutcome = "matched" | "ambiguous" | "no_match";
|
|
17
17
|
|
|
18
|
+
export interface DecisionCaseProvenance {
|
|
19
|
+
version: 1;
|
|
20
|
+
source: "authored" | "audit_import";
|
|
21
|
+
review_status: "human_labelled" | "unreviewed";
|
|
22
|
+
query_storage: "raw" | "redacted";
|
|
23
|
+
audit_id?: number;
|
|
24
|
+
labelled_at?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
export interface DecisionCase {
|
|
19
28
|
query: string;
|
|
20
29
|
split: DecisionSplit;
|
|
21
30
|
expected_outcome: DecisionOutcome;
|
|
22
31
|
relevant_skill_ids: string[];
|
|
32
|
+
provenance?: DecisionCaseProvenance;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
// ---------------------------------------------------------------------------
|
|
@@ -31,6 +41,14 @@ const rawCaseSchema = z.object({
|
|
|
31
41
|
split: z.enum(["tune", "test"]),
|
|
32
42
|
expected_outcome: z.enum(["matched", "ambiguous", "no_match"]),
|
|
33
43
|
relevant_skill_ids: z.array(z.string()),
|
|
44
|
+
provenance: z.object({
|
|
45
|
+
version: z.literal(1),
|
|
46
|
+
source: z.enum(["authored", "audit_import"]),
|
|
47
|
+
review_status: z.enum(["human_labelled", "unreviewed"]),
|
|
48
|
+
query_storage: z.enum(["raw", "redacted"]),
|
|
49
|
+
audit_id: z.number().int().positive().optional(),
|
|
50
|
+
labelled_at: z.string().datetime().optional(),
|
|
51
|
+
}).strict().optional(),
|
|
34
52
|
}).strict();
|
|
35
53
|
|
|
36
54
|
type RawCase = z.infer<typeof rawCaseSchema>;
|
|
@@ -45,6 +63,25 @@ function validateCase(raw: RawCase, idx: number): DecisionCase {
|
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
const { expected_outcome, relevant_skill_ids } = raw;
|
|
66
|
+
const provenance: DecisionCaseProvenance = raw.provenance ?? {
|
|
67
|
+
version: 1,
|
|
68
|
+
source: "authored",
|
|
69
|
+
review_status: "human_labelled",
|
|
70
|
+
query_storage: "raw",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
if (provenance.source === "audit_import") {
|
|
74
|
+
if (provenance.audit_id === undefined) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Validation error at case ${idx}: imported field "provenance.audit_id" is required`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (provenance.review_status !== "human_labelled" || !provenance.labelled_at) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Validation error at case ${idx}: imported audit case is unreviewed; human label and "provenance.labelled_at" are required for certification`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
48
85
|
|
|
49
86
|
if (expected_outcome === "matched") {
|
|
50
87
|
if (relevant_skill_ids.length !== 1) {
|
|
@@ -67,7 +104,85 @@ function validateCase(raw: RawCase, idx: number): DecisionCase {
|
|
|
67
104
|
}
|
|
68
105
|
}
|
|
69
106
|
|
|
70
|
-
return raw
|
|
107
|
+
return { ...raw, provenance };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface AuditFeedbackLabel {
|
|
111
|
+
split: DecisionSplit;
|
|
112
|
+
expected_outcome: DecisionOutcome;
|
|
113
|
+
relevant_skill_ids: string[];
|
|
114
|
+
labelled_at: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type AuditQueryPrivacy =
|
|
118
|
+
| { include_raw_query: true }
|
|
119
|
+
| { include_raw_query: false; redacted_query: string };
|
|
120
|
+
|
|
121
|
+
/** Import an audit outcome only after a separate human label is supplied. */
|
|
122
|
+
export function importLabelledAuditCase(
|
|
123
|
+
audit: AuditRow,
|
|
124
|
+
label: AuditFeedbackLabel,
|
|
125
|
+
privacy: AuditQueryPrivacy,
|
|
126
|
+
): DecisionCase {
|
|
127
|
+
const query = privacy.include_raw_query ? audit.query : privacy.redacted_query.trim();
|
|
128
|
+
if (!query) {
|
|
129
|
+
throw new Error("A non-empty redacted_query is required when raw audit queries are excluded");
|
|
130
|
+
}
|
|
131
|
+
const parsed = rawCaseSchema.parse({
|
|
132
|
+
query,
|
|
133
|
+
split: label.split,
|
|
134
|
+
expected_outcome: label.expected_outcome,
|
|
135
|
+
relevant_skill_ids: label.relevant_skill_ids,
|
|
136
|
+
provenance: {
|
|
137
|
+
version: 1,
|
|
138
|
+
source: "audit_import",
|
|
139
|
+
review_status: "human_labelled",
|
|
140
|
+
query_storage: privacy.include_raw_query ? "raw" : "redacted",
|
|
141
|
+
audit_id: audit.id,
|
|
142
|
+
labelled_at: label.labelled_at,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
return validateCase(parsed, audit.id);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface DatasetProvenanceSummary {
|
|
149
|
+
version: 1;
|
|
150
|
+
human_labelled_case_count: number;
|
|
151
|
+
imported_labelled_case_count: number;
|
|
152
|
+
imported_unreviewed_case_count: number;
|
|
153
|
+
raw_query_case_count: number;
|
|
154
|
+
redacted_query_case_count: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function summarizeDatasetProvenance(
|
|
158
|
+
cases: DecisionCase[],
|
|
159
|
+
): DatasetProvenanceSummary {
|
|
160
|
+
const provenance = (item: DecisionCase): DecisionCaseProvenance =>
|
|
161
|
+
item.provenance ?? {
|
|
162
|
+
version: 1,
|
|
163
|
+
source: "authored",
|
|
164
|
+
review_status: "human_labelled",
|
|
165
|
+
query_storage: "raw",
|
|
166
|
+
};
|
|
167
|
+
return {
|
|
168
|
+
version: 1,
|
|
169
|
+
human_labelled_case_count:
|
|
170
|
+
cases.filter((item) => provenance(item).review_status === "human_labelled").length,
|
|
171
|
+
imported_labelled_case_count:
|
|
172
|
+
cases.filter((item) =>
|
|
173
|
+
provenance(item).source === "audit_import" &&
|
|
174
|
+
provenance(item).review_status === "human_labelled"
|
|
175
|
+
).length,
|
|
176
|
+
imported_unreviewed_case_count:
|
|
177
|
+
cases.filter((item) =>
|
|
178
|
+
provenance(item).source === "audit_import" &&
|
|
179
|
+
provenance(item).review_status === "unreviewed"
|
|
180
|
+
).length,
|
|
181
|
+
raw_query_case_count:
|
|
182
|
+
cases.filter((item) => provenance(item).query_storage === "raw").length,
|
|
183
|
+
redacted_query_case_count:
|
|
184
|
+
cases.filter((item) => provenance(item).query_storage === "redacted").length,
|
|
185
|
+
};
|
|
71
186
|
}
|
|
72
187
|
|
|
73
188
|
// ---------------------------------------------------------------------------
|
|
@@ -113,8 +228,12 @@ function validateDatasetCompleteness(cases: DecisionCase[]): void {
|
|
|
113
228
|
* no_match → 0)
|
|
114
229
|
* - Dataset completeness (both splits, all outcome types in each split)
|
|
115
230
|
*/
|
|
116
|
-
export function loadDecisionCases(
|
|
231
|
+
export function loadDecisionCases(
|
|
232
|
+
raw: unknown[],
|
|
233
|
+
validSkillIds?: Iterable<string>,
|
|
234
|
+
): DecisionCase[] {
|
|
117
235
|
const parsed: DecisionCase[] = [];
|
|
236
|
+
const validIds = validSkillIds ? new Set(validSkillIds) : undefined;
|
|
118
237
|
|
|
119
238
|
for (let i = 0; i < raw.length; i++) {
|
|
120
239
|
const item = raw[i];
|
|
@@ -128,7 +247,17 @@ export function loadDecisionCases(raw: unknown[]): DecisionCase[] {
|
|
|
128
247
|
);
|
|
129
248
|
}
|
|
130
249
|
|
|
131
|
-
|
|
250
|
+
const parsedCase = validateCase(result.data, i);
|
|
251
|
+
if (validIds) {
|
|
252
|
+
for (const skillId of parsedCase.relevant_skill_ids) {
|
|
253
|
+
if (!validIds.has(skillId)) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`Validation error at case ${i}: field "relevant_skill_ids" references unknown vault skill "${skillId}"`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
parsed.push(parsedCase);
|
|
132
261
|
}
|
|
133
262
|
|
|
134
263
|
validateDatasetCompleteness(parsed);
|
|
@@ -139,9 +268,12 @@ export function loadDecisionCases(raw: unknown[]): DecisionCase[] {
|
|
|
139
268
|
* Read a JSON file from disk and validate it as a decision-policy dataset.
|
|
140
269
|
* Throws if the file cannot be read or the contents fail validation.
|
|
141
270
|
*/
|
|
142
|
-
export function loadDecisionCasesFromFile(
|
|
271
|
+
export function loadDecisionCasesFromFile(
|
|
272
|
+
path: string,
|
|
273
|
+
validSkillIds?: Iterable<string>,
|
|
274
|
+
): DecisionCase[] {
|
|
143
275
|
const raw = JSON.parse(readFileSync(path, "utf8")) as unknown[];
|
|
144
|
-
return loadDecisionCases(raw);
|
|
276
|
+
return loadDecisionCases(raw, validSkillIds);
|
|
145
277
|
}
|
|
146
278
|
|
|
147
279
|
// ---------------------------------------------------------------------------
|
|
@@ -734,6 +866,7 @@ export interface CalibrationRunRecord {
|
|
|
734
866
|
embedding_fingerprint: string;
|
|
735
867
|
corpus_fingerprint: string;
|
|
736
868
|
dataset_hash: string;
|
|
869
|
+
dataset_provenance?: DatasetProvenanceSummary;
|
|
737
870
|
candidate_limit: number;
|
|
738
871
|
attempt_count?: number;
|
|
739
872
|
min_auto_match_precision: number;
|
|
@@ -756,6 +889,8 @@ export interface CalibrationRunSummary {
|
|
|
756
889
|
embedding_fingerprint: string;
|
|
757
890
|
corpus_fingerprint: string;
|
|
758
891
|
dataset_hash: string;
|
|
892
|
+
human_labelled_case_count: number;
|
|
893
|
+
imported_labelled_case_count: number;
|
|
759
894
|
candidate_limit: number;
|
|
760
895
|
attempt_count: number;
|
|
761
896
|
min_auto_match_precision: number;
|
|
@@ -806,6 +941,15 @@ export function openCalibrateDb(stateDir: string): Database {
|
|
|
806
941
|
if (!columns.some((column) => column.name === "failed_reason")) {
|
|
807
942
|
db.run("ALTER TABLE calibration_runs ADD COLUMN failed_reason TEXT");
|
|
808
943
|
}
|
|
944
|
+
if (!columns.some((column) => column.name === "dataset_provenance")) {
|
|
945
|
+
db.run("ALTER TABLE calibration_runs ADD COLUMN dataset_provenance TEXT NOT NULL DEFAULT '{}'");
|
|
946
|
+
}
|
|
947
|
+
if (!columns.some((column) => column.name === "human_labelled_case_count")) {
|
|
948
|
+
db.run("ALTER TABLE calibration_runs ADD COLUMN human_labelled_case_count INTEGER NOT NULL DEFAULT 0");
|
|
949
|
+
}
|
|
950
|
+
if (!columns.some((column) => column.name === "imported_labelled_case_count")) {
|
|
951
|
+
db.run("ALTER TABLE calibration_runs ADD COLUMN imported_labelled_case_count INTEGER NOT NULL DEFAULT 0");
|
|
952
|
+
}
|
|
809
953
|
return db;
|
|
810
954
|
}
|
|
811
955
|
|
|
@@ -822,8 +966,9 @@ export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): v
|
|
|
822
966
|
candidate_limit,
|
|
823
967
|
attempt_count, min_auto_match_precision, min_auto_match_count,
|
|
824
968
|
min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason,
|
|
825
|
-
selected_thresholds, tune_metrics, test_metrics, observations
|
|
826
|
-
|
|
969
|
+
selected_thresholds, tune_metrics, test_metrics, observations,
|
|
970
|
+
dataset_provenance, human_labelled_case_count, imported_labelled_case_count
|
|
971
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
827
972
|
[
|
|
828
973
|
run.run_id,
|
|
829
974
|
run.created_at,
|
|
@@ -843,6 +988,9 @@ export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): v
|
|
|
843
988
|
run.tune_metrics != null ? JSON.stringify(run.tune_metrics) : null,
|
|
844
989
|
run.test_metrics != null ? JSON.stringify(run.test_metrics) : null,
|
|
845
990
|
JSON.stringify(run.observations),
|
|
991
|
+
JSON.stringify(run.dataset_provenance ?? {}),
|
|
992
|
+
run.dataset_provenance?.human_labelled_case_count ?? 0,
|
|
993
|
+
run.dataset_provenance?.imported_labelled_case_count ?? 0,
|
|
846
994
|
],
|
|
847
995
|
);
|
|
848
996
|
}
|
|
@@ -866,6 +1014,9 @@ interface RawCalibrationRow {
|
|
|
866
1014
|
tune_metrics: string | null;
|
|
867
1015
|
test_metrics: string | null;
|
|
868
1016
|
observations: string;
|
|
1017
|
+
dataset_provenance: string;
|
|
1018
|
+
human_labelled_case_count: number;
|
|
1019
|
+
imported_labelled_case_count: number;
|
|
869
1020
|
}
|
|
870
1021
|
|
|
871
1022
|
function parseMetrics(json: string): CalibrationMetrics {
|
|
@@ -900,6 +1051,10 @@ function rowToRecord(row: RawCalibrationRow): CalibrationRunRecord {
|
|
|
900
1051
|
embedding_fingerprint: row.embedding_fingerprint,
|
|
901
1052
|
corpus_fingerprint: row.corpus_fingerprint,
|
|
902
1053
|
dataset_hash: row.dataset_hash,
|
|
1054
|
+
dataset_provenance:
|
|
1055
|
+
Object.keys(JSON.parse(row.dataset_provenance) as object).length > 0
|
|
1056
|
+
? JSON.parse(row.dataset_provenance) as DatasetProvenanceSummary
|
|
1057
|
+
: undefined,
|
|
903
1058
|
candidate_limit: row.candidate_limit,
|
|
904
1059
|
attempt_count: row.attempt_count,
|
|
905
1060
|
min_auto_match_precision: row.min_auto_match_precision,
|
|
@@ -939,7 +1094,8 @@ export function listCalibrationRuns(db: Database): CalibrationRunSummary[] {
|
|
|
939
1094
|
reranker_fingerprint, embedding_fingerprint, corpus_fingerprint, dataset_hash,
|
|
940
1095
|
candidate_limit,
|
|
941
1096
|
attempt_count, min_auto_match_precision, min_auto_match_count,
|
|
942
|
-
min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason
|
|
1097
|
+
min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason,
|
|
1098
|
+
human_labelled_case_count, imported_labelled_case_count
|
|
943
1099
|
FROM calibration_runs ORDER BY created_at DESC`,
|
|
944
1100
|
)
|
|
945
1101
|
.all() as CalibrationRunSummary[];
|
package/src/cli.ts
CHANGED
|
@@ -132,6 +132,31 @@ const KNOWN_COMMANDS = [
|
|
|
132
132
|
"local-vault",
|
|
133
133
|
];
|
|
134
134
|
|
|
135
|
+
const DOCKER_HOST_MANAGEMENT_GUIDANCE =
|
|
136
|
+
"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.";
|
|
137
|
+
|
|
138
|
+
function isDockerHostManagementCommand(command: string, subCommand: string): boolean {
|
|
139
|
+
if (
|
|
140
|
+
[
|
|
141
|
+
"init",
|
|
142
|
+
"sync",
|
|
143
|
+
"install",
|
|
144
|
+
"project",
|
|
145
|
+
"target",
|
|
146
|
+
"core",
|
|
147
|
+
"local-vault",
|
|
148
|
+
"models",
|
|
149
|
+
"context",
|
|
150
|
+
"calibrate",
|
|
151
|
+
"eval",
|
|
152
|
+
].includes(command)
|
|
153
|
+
) {
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return command === "config" && ["init", "set"].includes(subCommand);
|
|
158
|
+
}
|
|
159
|
+
|
|
135
160
|
async function main() {
|
|
136
161
|
const rawArgv = Bun.argv.slice(2);
|
|
137
162
|
|
|
@@ -141,6 +166,8 @@ async function main() {
|
|
|
141
166
|
let flagContext: string | undefined;
|
|
142
167
|
let flagServer: string | undefined;
|
|
143
168
|
let isDryRun = false;
|
|
169
|
+
const subCommand = rawArgv[1] ?? "";
|
|
170
|
+
const commandArgs = rawArgv.slice(2);
|
|
144
171
|
|
|
145
172
|
const command = rawArgv[0];
|
|
146
173
|
if (!command || command === "--help" || command === "-h") {
|
|
@@ -161,6 +188,18 @@ async function main() {
|
|
|
161
188
|
|
|
162
189
|
let resolvedTarget: ResolvedTarget = { type: "local", name: "local" };
|
|
163
190
|
|
|
191
|
+
if (
|
|
192
|
+
process.env.RUNNING_IN_DOCKER === "true" &&
|
|
193
|
+
isDockerHostManagementCommand(command, subCommand)
|
|
194
|
+
) {
|
|
195
|
+
handleError(new Error(DOCKER_HOST_MANAGEMENT_GUIDANCE), {
|
|
196
|
+
target: resolvedTarget,
|
|
197
|
+
isJson,
|
|
198
|
+
isVerbose,
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
164
203
|
// Only resolve target if command is target-aware or context/config/calibrate
|
|
165
204
|
const isLocalConfigInit = command === "config" && rawArgv[1] === "init";
|
|
166
205
|
if (
|
|
@@ -181,8 +220,6 @@ async function main() {
|
|
|
181
220
|
}
|
|
182
221
|
|
|
183
222
|
const adapter = createTargetAdapter(resolvedTarget, { allowInsecure });
|
|
184
|
-
const subCommand = rawArgv[1] ?? "";
|
|
185
|
-
const commandArgs = rawArgv.slice(2);
|
|
186
223
|
|
|
187
224
|
try {
|
|
188
225
|
switch (command) {
|
|
@@ -446,7 +483,7 @@ async function handleCalibrateCommand(
|
|
|
446
483
|
minAutoMatchCount,
|
|
447
484
|
});
|
|
448
485
|
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
449
|
-
|
|
486
|
+
renderCalibrationTarget(ctx.target);
|
|
450
487
|
console.log(`Calibration run complete.`);
|
|
451
488
|
if (res.result) console.log(JSON.stringify(res.result, null, 2));
|
|
452
489
|
});
|
|
@@ -456,7 +493,7 @@ async function handleCalibrateCommand(
|
|
|
456
493
|
if (sub === "list") {
|
|
457
494
|
const res = await adapter.calibrateList();
|
|
458
495
|
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
459
|
-
|
|
496
|
+
renderCalibrationTarget(ctx.target);
|
|
460
497
|
renderTable(
|
|
461
498
|
[
|
|
462
499
|
{ key: "run_id", header: "RUN_ID" },
|
|
@@ -474,7 +511,7 @@ async function handleCalibrateCommand(
|
|
|
474
511
|
if (!runId) throw new Error("usage: skillmux calibrate show <run_id>");
|
|
475
512
|
const res = await adapter.calibrateShow(runId);
|
|
476
513
|
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
477
|
-
|
|
514
|
+
renderCalibrationTarget(ctx.target);
|
|
478
515
|
console.log(JSON.stringify(res, null, 2));
|
|
479
516
|
});
|
|
480
517
|
return;
|
|
@@ -485,7 +522,7 @@ async function handleCalibrateCommand(
|
|
|
485
522
|
if (!runId) throw new Error("usage: skillmux calibrate apply <run_id>");
|
|
486
523
|
const res = await adapter.calibrateApply(runId);
|
|
487
524
|
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
488
|
-
|
|
525
|
+
renderCalibrationTarget(ctx.target);
|
|
489
526
|
console.log(`Applied calibration run "${runId}"`);
|
|
490
527
|
});
|
|
491
528
|
return;
|
|
@@ -501,6 +538,14 @@ async function handleCalibrateCommand(
|
|
|
501
538
|
);
|
|
502
539
|
}
|
|
503
540
|
|
|
541
|
+
function renderCalibrationTarget(target: ResolvedTarget): void {
|
|
542
|
+
if (target.type === "local") {
|
|
543
|
+
console.log("Target: local");
|
|
544
|
+
} else {
|
|
545
|
+
console.log(`Target: remote (${target.name} -> ${target.server})`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
504
549
|
async function handleCompletionsCommand(shell: string) {
|
|
505
550
|
if (shell !== "bash" && shell !== "zsh" && shell !== "fish") {
|
|
506
551
|
throw new Error("usage: skillmux completions <bash|zsh|fish>");
|
package/src/config-watcher.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { watch } from "node:fs";
|
|
1
|
+
import { mkdirSync, watch } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { loadConfig } from "./config";
|
|
4
4
|
import type { Config } from "./types";
|
|
@@ -133,6 +133,10 @@ export class ConfigWatcher {
|
|
|
133
133
|
const dir = dirname(tomlPath);
|
|
134
134
|
const filename = tomlPath.split(/[/\\]/).pop()!;
|
|
135
135
|
|
|
136
|
+
// A config file is optional. Ensure its parent exists so zero-config
|
|
137
|
+
// startup is safe and later config writes are still observed.
|
|
138
|
+
mkdirSync(dir, { recursive: true });
|
|
139
|
+
|
|
136
140
|
this.watcher = watch(dir, { recursive: false }, (_event, changedName) => {
|
|
137
141
|
if (this.stopped) return;
|
|
138
142
|
// Fire for: the config file itself, or any .tmp variant of it (handles
|
package/src/dataset-generator.ts
CHANGED
|
@@ -13,14 +13,45 @@ export interface GenerateDatasetOptions {
|
|
|
13
13
|
queriesPerSplit?: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
const STOP_WORDS = new Set([
|
|
17
|
+
"a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "in", "is",
|
|
18
|
+
"it", "of", "on", "or", "the", "this", "to", "use", "with",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
function words(value: string): string[] {
|
|
22
|
+
return value
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.match(/[a-z0-9]+/g)
|
|
25
|
+
?.filter((word) => word.length > 2 && !STOP_WORDS.has(word)) ?? [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function anchors(skill: VaultSkill): string[] {
|
|
29
|
+
const preferred = [...skill.aliases.flatMap(words), ...words(skill.title)];
|
|
30
|
+
const fallback = words(skill.description);
|
|
31
|
+
return [...new Set([...preferred, ...fallback])].slice(0, 2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function matchedQuery(skill: VaultSkill, variant: number): string {
|
|
35
|
+
const [first = "specialized", second = "workflow"] = anchors(skill);
|
|
36
|
+
const templates = [
|
|
37
|
+
`I need practical guidance completing an unfamiliar ${first} ${second} task safely`,
|
|
38
|
+
`Which available workflow can handle my unusual ${first} ${second} problem end to end`,
|
|
39
|
+
`Please guide me through a difficult unfamiliar ${first} ${second} operation safely`,
|
|
40
|
+
];
|
|
41
|
+
return templates[variant % templates.length]!;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ambiguousQuery(first: VaultSkill, second: VaultSkill): string {
|
|
45
|
+
const [firstAnchor = "first"] = anchors(first);
|
|
46
|
+
const [secondAnchor = "second"] = anchors(second);
|
|
47
|
+
return `Help with a workflow spanning both ${firstAnchor} and ${secondAnchor} responsibilities`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function nearMissQuery(first: VaultSkill, second: VaultSkill): string {
|
|
51
|
+
const [firstAnchor = "one"] = anchors(first);
|
|
52
|
+
const [secondAnchor = "another"] = anchors(second);
|
|
53
|
+
return `Explain the theory comparing ${firstAnchor} and ${secondAnchor} without performing either workflow`;
|
|
54
|
+
}
|
|
24
55
|
|
|
25
56
|
/**
|
|
26
57
|
* Automatically generate a synthetic decision-policy calibration dataset
|
|
@@ -30,110 +61,58 @@ export function generateDataset(
|
|
|
30
61
|
skills: VaultSkill[],
|
|
31
62
|
options: GenerateDatasetOptions = {},
|
|
32
63
|
): RawDecisionCase[] {
|
|
33
|
-
|
|
64
|
+
if (skills.length < 4) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
"Dataset generation requires at least 4 vault skills so tune and test can each contain matched and ambiguous cases without skill leakage",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
34
69
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
});
|
|
70
|
+
const cases: RawDecisionCase[] = [];
|
|
71
|
+
const sorted = [...skills].sort((a, b) => a.skill_id.localeCompare(b.skill_id));
|
|
72
|
+
const splitAt = Math.ceil(sorted.length / 2);
|
|
73
|
+
const bySplit: Record<DecisionSplit, VaultSkill[]> = {
|
|
74
|
+
tune: sorted.slice(0, splitAt),
|
|
75
|
+
test: sorted.slice(splitAt),
|
|
76
|
+
};
|
|
77
|
+
const targetPerSplit = Math.max(3, options.queriesPerSplit ?? 10);
|
|
44
78
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
split: "test",
|
|
50
|
-
expected_outcome: "matched",
|
|
51
|
-
relevant_skill_ids: [skill.skill_id],
|
|
52
|
-
});
|
|
53
|
-
} else {
|
|
79
|
+
for (const split of ["tune", "test"] as const) {
|
|
80
|
+
const splitSkills = bySplit[split];
|
|
81
|
+
for (let i = 0; i < splitSkills.length; i++) {
|
|
82
|
+
const skill = splitSkills[i]!;
|
|
54
83
|
cases.push({
|
|
55
|
-
query:
|
|
56
|
-
split
|
|
84
|
+
query: matchedQuery(skill, i),
|
|
85
|
+
split,
|
|
57
86
|
expected_outcome: "matched",
|
|
58
87
|
relevant_skill_ids: [skill.skill_id],
|
|
59
88
|
});
|
|
60
89
|
}
|
|
61
|
-
}
|
|
62
90
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// Pair skills for ambiguous multi-match
|
|
66
|
-
for (let i = 0; i < skills.length - 1; i += 2) {
|
|
67
|
-
const s1 = skills[i]!;
|
|
68
|
-
const s2 = skills[i + 1]!;
|
|
69
|
-
const split: DecisionSplit = i % 4 === 0 ? "tune" : "test";
|
|
70
|
-
cases.push({
|
|
71
|
-
query: `automated task using ${s1.title} and ${s2.title}`,
|
|
72
|
-
split,
|
|
73
|
-
expected_outcome: "ambiguous",
|
|
74
|
-
relevant_skill_ids: [s1.skill_id, s2.skill_id],
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
} else {
|
|
78
|
-
// Fallback ambiguous cases if fewer than 2 skills
|
|
79
|
-
cases.push({
|
|
80
|
-
query: "automate browser workflow testing",
|
|
81
|
-
split: "tune",
|
|
82
|
-
expected_outcome: "ambiguous",
|
|
83
|
-
relevant_skill_ids: ["mock-e2e", "mock-browser"],
|
|
84
|
-
});
|
|
85
|
-
cases.push({
|
|
86
|
-
query: "extract and fetch clean web text",
|
|
87
|
-
split: "test",
|
|
88
|
-
expected_outcome: "ambiguous",
|
|
89
|
-
relevant_skill_ids: ["mock-fetch", "mock-extract"],
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Ensure both tune and test have ambiguous cases
|
|
94
|
-
if (!cases.some((c) => c.split === "tune" && c.expected_outcome === "ambiguous")) {
|
|
95
|
-
const sIds = skills.length >= 2 ? [skills[0]!.skill_id, skills[1]!.skill_id] : ["mock-a", "mock-b"];
|
|
91
|
+
const first = splitSkills[0]!;
|
|
92
|
+
const second = splitSkills[1]!;
|
|
96
93
|
cases.push({
|
|
97
|
-
query:
|
|
98
|
-
split
|
|
94
|
+
query: ambiguousQuery(first, second),
|
|
95
|
+
split,
|
|
99
96
|
expected_outcome: "ambiguous",
|
|
100
|
-
relevant_skill_ids:
|
|
97
|
+
relevant_skill_ids: [first.skill_id, second.skill_id],
|
|
101
98
|
});
|
|
102
|
-
}
|
|
103
|
-
if (!cases.some((c) => c.split === "test" && c.expected_outcome === "ambiguous")) {
|
|
104
|
-
const sIds = skills.length >= 2 ? [skills[0]!.skill_id, skills[1]!.skill_id] : ["mock-a", "mock-b"];
|
|
105
99
|
cases.push({
|
|
106
|
-
query:
|
|
107
|
-
split
|
|
108
|
-
expected_outcome: "ambiguous",
|
|
109
|
-
relevant_skill_ids: sIds,
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// --- 3. No Match Cases ---
|
|
114
|
-
GENERIC_NO_MATCH_QUERIES.forEach((q, idx) => {
|
|
115
|
-
cases.push({
|
|
116
|
-
query: q,
|
|
117
|
-
split: idx % 2 === 0 ? "tune" : "test",
|
|
100
|
+
query: nearMissQuery(first, second),
|
|
101
|
+
split,
|
|
118
102
|
expected_outcome: "no_match",
|
|
119
103
|
relevant_skill_ids: [],
|
|
120
104
|
});
|
|
121
|
-
});
|
|
122
105
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
split: "test",
|
|
134
|
-
expected_outcome: "matched",
|
|
135
|
-
relevant_skill_ids: ["mock-docs"],
|
|
136
|
-
});
|
|
106
|
+
for (let i = cases.filter((item) => item.split === split).length; i < targetPerSplit; i++) {
|
|
107
|
+
const left = splitSkills[i % splitSkills.length]!;
|
|
108
|
+
const right = splitSkills[(i + 1) % splitSkills.length]!;
|
|
109
|
+
cases.push({
|
|
110
|
+
query: i % 2 === 0 ? matchedQuery(left, i) : nearMissQuery(left, right),
|
|
111
|
+
split,
|
|
112
|
+
expected_outcome: i % 2 === 0 ? "matched" : "no_match",
|
|
113
|
+
relevant_skill_ids: i % 2 === 0 ? [left.skill_id] : [],
|
|
114
|
+
});
|
|
115
|
+
}
|
|
137
116
|
}
|
|
138
117
|
|
|
139
118
|
return cases;
|