@klhapp/skillmux 0.2.1 → 0.4.3
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 +76 -0
- package/README.md +72 -3
- package/config.example.toml +5 -0
- package/docs/configuration.md +58 -4
- package/docs/releasing.md +60 -21
- package/package.json +1 -1
- package/src/adapters.ts +381 -0
- package/src/calibrate.ts +797 -0
- package/src/cli.ts +866 -385
- package/src/clients.ts +3 -1
- package/src/completions.ts +84 -0
- package/src/config-service.ts +408 -0
- package/src/config-watcher.ts +179 -0
- package/src/config.ts +7 -0
- package/src/context.ts +183 -0
- package/src/dataset-generator.ts +140 -0
- package/src/doctor.ts +43 -0
- package/src/eval.ts +22 -4
- package/src/init.ts +1 -1
- package/src/manifest.ts +124 -10
- package/src/output.ts +151 -0
- package/src/router-core.ts +60 -12
- package/src/server.ts +119 -0
- package/src/snapshot.ts +135 -0
- package/src/sync.ts +29 -8
- package/src/types.ts +7 -0
- package/src/vault.ts +59 -1
package/src/config.ts
CHANGED
|
@@ -27,6 +27,7 @@ const remoteThresholdsSchema = z.object({
|
|
|
27
27
|
|
|
28
28
|
const configSchema = z.object({
|
|
29
29
|
vault_path: z.string().min(1),
|
|
30
|
+
local_vault_paths: z.array(z.string()),
|
|
30
31
|
state_dir: z.string().min(1),
|
|
31
32
|
recall: z.object({ k_lexical: z.number().int().positive(), k_vector: z.number().int().positive() }).strict(),
|
|
32
33
|
thresholds: z.object({
|
|
@@ -59,6 +60,7 @@ const configSchema = z.object({
|
|
|
59
60
|
api_key_env: z.string().min(1).optional(),
|
|
60
61
|
}).strict().optional(),
|
|
61
62
|
thresholds: remoteThresholdsSchema.optional(),
|
|
63
|
+
calibration: z.object({ run_id: z.string().min(1) }).strict().optional(),
|
|
62
64
|
}).strict(),
|
|
63
65
|
]),
|
|
64
66
|
server: z.object({
|
|
@@ -71,6 +73,10 @@ const configSchema = z.object({
|
|
|
71
73
|
requests_per_minute: z.number().int().positive(),
|
|
72
74
|
trust_proxy: z.boolean().optional(),
|
|
73
75
|
}).strict().optional(),
|
|
76
|
+
admin: z.object({
|
|
77
|
+
enabled: z.boolean(),
|
|
78
|
+
token_env: z.string().min(1),
|
|
79
|
+
}).strict().optional(),
|
|
74
80
|
}).strict().optional(),
|
|
75
81
|
}).strict();
|
|
76
82
|
|
|
@@ -80,6 +86,7 @@ export const LOCAL_BUNDLE_ID = "gte-small-v1";
|
|
|
80
86
|
|
|
81
87
|
const DEFAULTS: Config = {
|
|
82
88
|
vault_path: "~/skills",
|
|
89
|
+
local_vault_paths: [],
|
|
83
90
|
state_dir: "~/.local/state/skillmux",
|
|
84
91
|
recall: { k_lexical: 20, k_vector: 20 },
|
|
85
92
|
thresholds: { candidate_limit: 5 },
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { expandHome } from "./config";
|
|
4
|
+
|
|
5
|
+
export interface ContextRecord {
|
|
6
|
+
server: string;
|
|
7
|
+
token_env?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ContextConfig {
|
|
11
|
+
default_context: string;
|
|
12
|
+
contexts: Record<string, ContextRecord>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ResolvedTarget =
|
|
16
|
+
| { type: "local"; name: "local" }
|
|
17
|
+
| { type: "remote"; name: string; server: string; token_env?: string };
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_CONTEXTS_PATH = "~/.config/skillmux/contexts.toml";
|
|
20
|
+
|
|
21
|
+
export async function loadContextConfig(filePath?: string): Promise<ContextConfig> {
|
|
22
|
+
const targetPath = expandHome(filePath ?? DEFAULT_CONTEXTS_PATH);
|
|
23
|
+
const base: ContextConfig = {
|
|
24
|
+
default_context: "local",
|
|
25
|
+
contexts: { local: { server: "local" } },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
if (!existsSync(targetPath)) {
|
|
29
|
+
return base;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const text = await Bun.file(targetPath).text();
|
|
34
|
+
const parsed = Bun.TOML.parse(text) as Partial<ContextConfig>;
|
|
35
|
+
const default_context = typeof parsed.default_context === "string" ? parsed.default_context : "local";
|
|
36
|
+
const contexts: Record<string, ContextRecord> = { local: { server: "local" } };
|
|
37
|
+
|
|
38
|
+
if (parsed.contexts && typeof parsed.contexts === "object") {
|
|
39
|
+
for (const [name, rec] of Object.entries(parsed.contexts)) {
|
|
40
|
+
if (name === "local") continue;
|
|
41
|
+
if (rec && typeof rec === "object" && typeof (rec as ContextRecord).server === "string") {
|
|
42
|
+
contexts[name] = {
|
|
43
|
+
server: (rec as ContextRecord).server,
|
|
44
|
+
...(typeof (rec as ContextRecord).token_env === "string" ? { token_env: (rec as ContextRecord).token_env } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { default_context, contexts };
|
|
51
|
+
} catch {
|
|
52
|
+
return base;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function saveContextConfig(config: ContextConfig, filePath?: string): Promise<void> {
|
|
57
|
+
const targetPath = expandHome(filePath ?? DEFAULT_CONTEXTS_PATH);
|
|
58
|
+
const dir = dirname(targetPath);
|
|
59
|
+
mkdirSync(dir, { recursive: true });
|
|
60
|
+
|
|
61
|
+
// Serialize to TOML
|
|
62
|
+
let tomlStr = `default_context = "${config.default_context}"\n\n[contexts]\n`;
|
|
63
|
+
for (const [name, rec] of Object.entries(config.contexts)) {
|
|
64
|
+
if (name === "local") {
|
|
65
|
+
tomlStr += `[contexts.local]\nserver = "local"\n\n`;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
tomlStr += `[contexts.${name}]\nserver = "${rec.server}"\n`;
|
|
69
|
+
if (rec.token_env) {
|
|
70
|
+
tomlStr += `token_env = "${rec.token_env}"\n`;
|
|
71
|
+
}
|
|
72
|
+
tomlStr += `\n`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const tmpPath = join(dir, `.contexts-${Math.random().toString(36).slice(2)}.tmp`);
|
|
76
|
+
writeFileSync(tmpPath, tomlStr, "utf-8");
|
|
77
|
+
renameSync(tmpPath, targetPath);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function listContexts(filePath?: string): Promise<Array<{ name: string; server: string; token_env?: string; isDefault: boolean }>> {
|
|
81
|
+
const config = await loadContextConfig(filePath);
|
|
82
|
+
return Object.entries(config.contexts).map(([name, rec]) => ({
|
|
83
|
+
name,
|
|
84
|
+
server: rec.server,
|
|
85
|
+
token_env: rec.token_env,
|
|
86
|
+
isDefault: name === config.default_context,
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function getCurrentContext(filePath?: string): Promise<{ name: string; server: string; token_env?: string }> {
|
|
91
|
+
const config = await loadContextConfig(filePath);
|
|
92
|
+
const name = config.contexts[config.default_context] ? config.default_context : "local";
|
|
93
|
+
const rec = config.contexts[name] ?? { server: "local" };
|
|
94
|
+
return { name, server: rec.server, token_env: rec.token_env };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function addContext(name: string, record: ContextRecord, filePath?: string): Promise<void> {
|
|
98
|
+
if (name === "local") {
|
|
99
|
+
throw new Error('Context "local" is reserved and cannot be added or modified');
|
|
100
|
+
}
|
|
101
|
+
const config = await loadContextConfig(filePath);
|
|
102
|
+
config.contexts[name] = record;
|
|
103
|
+
await saveContextConfig(config, filePath);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function removeContext(name: string, filePath?: string): Promise<void> {
|
|
107
|
+
if (name === "local") {
|
|
108
|
+
throw new Error('Context "local" is reserved and cannot be removed');
|
|
109
|
+
}
|
|
110
|
+
const config = await loadContextConfig(filePath);
|
|
111
|
+
if (!config.contexts[name]) {
|
|
112
|
+
throw new Error(`Context "${name}" does not exist`);
|
|
113
|
+
}
|
|
114
|
+
delete config.contexts[name];
|
|
115
|
+
if (config.default_context === name) {
|
|
116
|
+
config.default_context = "local";
|
|
117
|
+
}
|
|
118
|
+
await saveContextConfig(config, filePath);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function useContext(name: string, filePath?: string): Promise<void> {
|
|
122
|
+
const config = await loadContextConfig(filePath);
|
|
123
|
+
if (!config.contexts[name]) {
|
|
124
|
+
throw new Error(`Context "${name}" does not exist`);
|
|
125
|
+
}
|
|
126
|
+
config.default_context = name;
|
|
127
|
+
await saveContextConfig(config, filePath);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function resolveTarget(
|
|
131
|
+
flags: { context?: string; server?: string },
|
|
132
|
+
filePath?: string
|
|
133
|
+
): Promise<ResolvedTarget> {
|
|
134
|
+
// Precedence 1: Explicit flags
|
|
135
|
+
if (flags.context && flags.server) {
|
|
136
|
+
throw new Error("Cannot specify both --context and --server");
|
|
137
|
+
}
|
|
138
|
+
if (flags.context) {
|
|
139
|
+
if (flags.context === "local") return { type: "local", name: "local" };
|
|
140
|
+
const config = await loadContextConfig(filePath);
|
|
141
|
+
const rec = config.contexts[flags.context];
|
|
142
|
+
if (!rec) {
|
|
143
|
+
throw new Error(`Specified context "${flags.context}" does not exist`);
|
|
144
|
+
}
|
|
145
|
+
if (rec.server === "local") return { type: "local", name: "local" };
|
|
146
|
+
return { type: "remote", name: flags.context, server: rec.server, token_env: rec.token_env };
|
|
147
|
+
}
|
|
148
|
+
if (flags.server) {
|
|
149
|
+
if (flags.server === "local") return { type: "local", name: "local" };
|
|
150
|
+
return { type: "remote", name: "custom", server: flags.server };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Precedence 2: Environment variables
|
|
154
|
+
const envContext = process.env.SKILLMUX_CONTEXT;
|
|
155
|
+
const envServer = process.env.SKILLMUX_SERVER;
|
|
156
|
+
if (envContext && envServer) {
|
|
157
|
+
throw new Error("Cannot specify both SKILLMUX_CONTEXT and SKILLMUX_SERVER");
|
|
158
|
+
}
|
|
159
|
+
if (envContext) {
|
|
160
|
+
if (envContext === "local") return { type: "local", name: "local" };
|
|
161
|
+
const config = await loadContextConfig(filePath);
|
|
162
|
+
const rec = config.contexts[envContext];
|
|
163
|
+
if (!rec) {
|
|
164
|
+
throw new Error(`Configured SKILLMUX_CONTEXT "${envContext}" does not exist`);
|
|
165
|
+
}
|
|
166
|
+
if (rec.server === "local") return { type: "local", name: "local" };
|
|
167
|
+
return { type: "remote", name: envContext, server: rec.server, token_env: rec.token_env };
|
|
168
|
+
}
|
|
169
|
+
if (envServer) {
|
|
170
|
+
if (envServer === "local") return { type: "local", name: "local" };
|
|
171
|
+
return { type: "remote", name: "custom", server: envServer };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Precedence 3 & 4: Configured default context -> local
|
|
175
|
+
const config = await loadContextConfig(filePath);
|
|
176
|
+
const defName = config.contexts[config.default_context] ? config.default_context : "local";
|
|
177
|
+
const defRec = config.contexts[defName] ?? { server: "local" };
|
|
178
|
+
|
|
179
|
+
if (defName === "local" || defRec.server === "local") {
|
|
180
|
+
return { type: "local", name: "local" };
|
|
181
|
+
}
|
|
182
|
+
return { type: "remote", name: defName, server: defRec.server, token_env: defRec.token_env };
|
|
183
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { DecisionSplit, DecisionOutcome } from "./calibrate";
|
|
2
|
+
import type { VaultSkill } from "./vault";
|
|
3
|
+
|
|
4
|
+
export interface RawDecisionCase {
|
|
5
|
+
query: string;
|
|
6
|
+
split: DecisionSplit;
|
|
7
|
+
expected_outcome: DecisionOutcome;
|
|
8
|
+
relevant_skill_ids: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface GenerateDatasetOptions {
|
|
12
|
+
/** Target number of queries per split. Default: 10. */
|
|
13
|
+
queriesPerSplit?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const GENERIC_NO_MATCH_QUERIES = [
|
|
17
|
+
"what is the weather in Paris today",
|
|
18
|
+
"recipe for baking sourdough bread at home",
|
|
19
|
+
"what is the distance from Earth to Mars",
|
|
20
|
+
"explain quantum entanglement simply",
|
|
21
|
+
"how do I solve a quadratic equation",
|
|
22
|
+
"who won the 1998 World Cup",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Automatically generate a synthetic decision-policy calibration dataset
|
|
27
|
+
* from local vault skill definitions. Runs 100% locally with zero data leaks.
|
|
28
|
+
*/
|
|
29
|
+
export function generateDataset(
|
|
30
|
+
skills: VaultSkill[],
|
|
31
|
+
options: GenerateDatasetOptions = {},
|
|
32
|
+
): RawDecisionCase[] {
|
|
33
|
+
const cases: RawDecisionCase[] = [];
|
|
34
|
+
|
|
35
|
+
// --- 1. Matched Cases ---
|
|
36
|
+
for (const skill of skills) {
|
|
37
|
+
// Primary query from title + description
|
|
38
|
+
cases.push({
|
|
39
|
+
query: `how do I ${skill.title.toLowerCase()}: ${skill.description.toLowerCase()}`,
|
|
40
|
+
split: "tune",
|
|
41
|
+
expected_outcome: "matched",
|
|
42
|
+
relevant_skill_ids: [skill.skill_id],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Secondary query from aliases
|
|
46
|
+
if (skill.aliases.length > 0) {
|
|
47
|
+
cases.push({
|
|
48
|
+
query: `help me with ${skill.aliases[0]}`,
|
|
49
|
+
split: "test",
|
|
50
|
+
expected_outcome: "matched",
|
|
51
|
+
relevant_skill_ids: [skill.skill_id],
|
|
52
|
+
});
|
|
53
|
+
} else {
|
|
54
|
+
cases.push({
|
|
55
|
+
query: `execute task related to ${skill.title}`,
|
|
56
|
+
split: "test",
|
|
57
|
+
expected_outcome: "matched",
|
|
58
|
+
relevant_skill_ids: [skill.skill_id],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- 2. Ambiguous Cases ---
|
|
64
|
+
if (skills.length >= 2) {
|
|
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"];
|
|
96
|
+
cases.push({
|
|
97
|
+
query: "integrated workflow multi skill query",
|
|
98
|
+
split: "tune",
|
|
99
|
+
expected_outcome: "ambiguous",
|
|
100
|
+
relevant_skill_ids: sIds,
|
|
101
|
+
});
|
|
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
|
+
cases.push({
|
|
106
|
+
query: "combined operations multi skill query",
|
|
107
|
+
split: "test",
|
|
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",
|
|
118
|
+
expected_outcome: "no_match",
|
|
119
|
+
relevant_skill_ids: [],
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Ensure both splits have at least 1 matched case if skills were empty
|
|
124
|
+
if (skills.length === 0) {
|
|
125
|
+
cases.push({
|
|
126
|
+
query: "run mock container action",
|
|
127
|
+
split: "tune",
|
|
128
|
+
expected_outcome: "matched",
|
|
129
|
+
relevant_skill_ids: ["mock-container"],
|
|
130
|
+
});
|
|
131
|
+
cases.push({
|
|
132
|
+
query: "search mock API docs",
|
|
133
|
+
split: "test",
|
|
134
|
+
expected_outcome: "matched",
|
|
135
|
+
relevant_skill_ids: ["mock-docs"],
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return cases;
|
|
140
|
+
}
|
package/src/doctor.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { createClients } from "./clients";
|
|
3
3
|
import { embeddingDimension, expandHome } from "./config";
|
|
4
|
+
import { resolveManifestPath } from "./manifest";
|
|
5
|
+
import { readSkillmuxMarker } from "./sync";
|
|
4
6
|
import type { Config } from "./types";
|
|
7
|
+
import { findShadowedSkills } from "./vault";
|
|
5
8
|
|
|
6
9
|
export interface DoctorCheck {
|
|
7
10
|
name: string;
|
|
@@ -19,6 +22,46 @@ export async function diagnose(config: Config): Promise<DoctorReport> {
|
|
|
19
22
|
const checks: DoctorCheck[] = [];
|
|
20
23
|
checks.push({ name: "vault", ok: existsSync(expandHome(config.vault_path)), detail: expandHome(config.vault_path) });
|
|
21
24
|
|
|
25
|
+
for (const localPath of config.local_vault_paths) {
|
|
26
|
+
const expanded = expandHome(localPath);
|
|
27
|
+
checks.push({ name: `local_vault:${localPath}`, ok: existsSync(expanded), detail: expanded });
|
|
28
|
+
|
|
29
|
+
const strayManifest = resolveManifestPath(expanded);
|
|
30
|
+
if (strayManifest) {
|
|
31
|
+
checks.push({
|
|
32
|
+
name: `local_vault_manifest:${localPath}`,
|
|
33
|
+
ok: false,
|
|
34
|
+
detail: `stray manifest at ${strayManifest} — skillmux.toml only ever lives in vault_path, never in local_vault_paths`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const marker = readSkillmuxMarker(expanded);
|
|
39
|
+
const currentVaultPath = expandHome(config.vault_path);
|
|
40
|
+
if (!marker || marker.role !== "local_vault") {
|
|
41
|
+
checks.push({
|
|
42
|
+
name: `local_vault_marker:${localPath}`,
|
|
43
|
+
ok: false,
|
|
44
|
+
detail: `no marker — run: skillmux local-vault init "${expanded}"`,
|
|
45
|
+
});
|
|
46
|
+
} else if (marker.vault_path !== currentVaultPath) {
|
|
47
|
+
checks.push({
|
|
48
|
+
name: `local_vault_marker:${localPath}`,
|
|
49
|
+
ok: false,
|
|
50
|
+
detail: `marker recorded vault_path ${marker.vault_path}, currently configured vault_path is ${currentVaultPath} — drift, re-run skillmux local-vault init`,
|
|
51
|
+
});
|
|
52
|
+
} else {
|
|
53
|
+
checks.push({ name: `local_vault_marker:${localPath}`, ok: true, detail: expanded });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const shadow of findShadowedSkills(expandHome(config.vault_path), config.local_vault_paths.map(expandHome))) {
|
|
58
|
+
checks.push({
|
|
59
|
+
name: `shadowed:${shadow.skill_id}`,
|
|
60
|
+
ok: true,
|
|
61
|
+
detail: `served from ${shadow.winner}; shadows ${shadow.shadowed.join(", ")}`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
22
65
|
try {
|
|
23
66
|
mkdirSync(expandHome(config.state_dir), { recursive: true });
|
|
24
67
|
const probe = Bun.file(expandHome(`${config.state_dir}/.doctor`));
|
package/src/eval.ts
CHANGED
|
@@ -11,10 +11,14 @@ export interface EvalCase {
|
|
|
11
11
|
expected: string[];
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
const
|
|
14
|
+
const rawEvalItemSchema = z.object({
|
|
15
15
|
query: z.string().min(1),
|
|
16
|
-
expected: z.array(z.string().min(1)).
|
|
17
|
-
|
|
16
|
+
expected: z.array(z.string().min(1)).optional(),
|
|
17
|
+
relevant_skill_ids: z.array(z.string()).optional(),
|
|
18
|
+
split: z.string().optional(),
|
|
19
|
+
expected_outcome: z.string().optional(),
|
|
20
|
+
});
|
|
21
|
+
|
|
18
22
|
|
|
19
23
|
export interface EvalMetrics {
|
|
20
24
|
recall_at_3: number;
|
|
@@ -48,9 +52,23 @@ function metrics(rankings: string[][], cases: EvalCase[]): EvalMetrics {
|
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
export function loadEvalCases(path = join(import.meta.dir, "..", "eval", "queries.json")): EvalCase[] {
|
|
51
|
-
|
|
55
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
56
|
+
if (!Array.isArray(raw)) throw new Error("Eval cases file must contain a JSON array");
|
|
57
|
+
const parsed = z.array(rawEvalItemSchema).parse(raw);
|
|
58
|
+
const result: EvalCase[] = [];
|
|
59
|
+
for (const item of parsed) {
|
|
60
|
+
const expected = item.expected ?? item.relevant_skill_ids;
|
|
61
|
+
if (expected && expected.length > 0) {
|
|
62
|
+
result.push({ query: item.query, expected });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (parsed.length > 0 && result.length === 0) {
|
|
66
|
+
throw new Error("Eval cases file contained no cases with expected targets");
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
52
69
|
}
|
|
53
70
|
|
|
71
|
+
|
|
54
72
|
export async function evalVault(cases = loadEvalCases()): Promise<EvalReport> {
|
|
55
73
|
const { config, db, clients } = await getRuntime();
|
|
56
74
|
if (config.inference.mode !== "local") throw new Error('Default evaluation requires inference.mode = "local".');
|
package/src/init.ts
CHANGED
|
@@ -80,7 +80,7 @@ export function applyInit(vaultPath: string, confirmedTargets: ConfirmedTarget[]
|
|
|
80
80
|
const manifest: Manifest = {
|
|
81
81
|
...proposeManifest([]),
|
|
82
82
|
targets: Object.fromEntries(
|
|
83
|
-
confirmedTargets.map((target) => [target.name, { dir: target.dir,
|
|
83
|
+
confirmedTargets.map((target) => [target.name, { dir: target.dir, project_groups: [] }]),
|
|
84
84
|
),
|
|
85
85
|
};
|
|
86
86
|
|
package/src/manifest.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { expandHome } from "./config";
|
|
5
|
-
import { SKILL_ID_PATTERN } from "./vault";
|
|
5
|
+
import { resolveSkillRoot, SKILL_ID_PATTERN } from "./vault";
|
|
6
6
|
|
|
7
7
|
export const MANIFEST_FILENAME = "skillmux.toml";
|
|
8
8
|
export const LEGACY_MANIFEST_FILENAME = "skr.toml";
|
|
@@ -25,7 +25,7 @@ const projectGroupSchema = z.object({
|
|
|
25
25
|
|
|
26
26
|
const targetSchema = z.object({
|
|
27
27
|
dir: z.string().min(1),
|
|
28
|
-
|
|
28
|
+
project_groups: z.array(groupNameSchema).default([]),
|
|
29
29
|
}).strict();
|
|
30
30
|
|
|
31
31
|
const manifestSchema = z.object({
|
|
@@ -40,7 +40,24 @@ export type Manifest = z.infer<typeof manifestSchema>;
|
|
|
40
40
|
|
|
41
41
|
export function parseManifest(toml: string): Manifest {
|
|
42
42
|
const parsed = Bun.TOML.parse(toml) as Record<string, unknown>;
|
|
43
|
-
|
|
43
|
+
try {
|
|
44
|
+
return manifestSchema.parse(parsed);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error instanceof z.ZodError) {
|
|
47
|
+
for (const issue of error.issues) {
|
|
48
|
+
if (
|
|
49
|
+
issue.code === "unrecognized_keys" &&
|
|
50
|
+
issue.path[0] === "targets" &&
|
|
51
|
+
issue.keys.includes("project")
|
|
52
|
+
) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`[targets.${String(issue.path[1])}] uses the removed field "project" (boolean) — replace it with "project_groups" (an array of [project.<group>] names).`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
function tomlStringArray(values: string[]): string {
|
|
@@ -58,19 +75,111 @@ export function serializeManifest(manifest: Manifest): string {
|
|
|
58
75
|
}
|
|
59
76
|
|
|
60
77
|
for (const [name, target] of Object.entries(manifest.targets)) {
|
|
61
|
-
sections.push(
|
|
78
|
+
sections.push(
|
|
79
|
+
`[targets.${name}]\ndir = ${JSON.stringify(target.dir)}\nproject_groups = ${tomlStringArray(target.project_groups)}`,
|
|
80
|
+
);
|
|
62
81
|
}
|
|
63
82
|
|
|
64
83
|
return `${sections.join("\n\n")}\n`;
|
|
65
84
|
}
|
|
66
85
|
|
|
86
|
+
function findExistingPin(manifest: Manifest, skillId: string): string | null {
|
|
87
|
+
if (manifest.core.skills.includes(skillId)) return "[core]";
|
|
88
|
+
for (const [groupName, group] of Object.entries(manifest.project ?? {})) {
|
|
89
|
+
if (group.skills.includes(skillId)) return `[project.${groupName}]`;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function pinCore(manifest: Manifest, skillId: string): Manifest {
|
|
95
|
+
const existing = findExistingPin(manifest, skillId);
|
|
96
|
+
if (existing) {
|
|
97
|
+
throw new Error(`skill "${skillId}" already pinned in ${existing}`);
|
|
98
|
+
}
|
|
99
|
+
return { ...manifest, core: { skills: [...manifest.core.skills, skillId] } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function unpinCore(manifest: Manifest, skillId: string): Manifest {
|
|
103
|
+
if (!manifest.core.skills.includes(skillId)) {
|
|
104
|
+
throw new Error(`skill "${skillId}" is not pinned in [core]`);
|
|
105
|
+
}
|
|
106
|
+
return { ...manifest, core: { skills: manifest.core.skills.filter((id) => id !== skillId) } };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function pinProject(manifest: Manifest, skillId: string, group: string, repos?: string[]): Manifest {
|
|
110
|
+
if (!groupNameSchema.safeParse(group).success) {
|
|
111
|
+
throw new Error(`invalid group name "${group}" — must match /^[a-z][a-z0-9_-]*$/ (max 64 chars)`);
|
|
112
|
+
}
|
|
113
|
+
const existingGroup = manifest.project?.[group];
|
|
114
|
+
|
|
115
|
+
if (!existingGroup) {
|
|
116
|
+
if (!repos || repos.length === 0) {
|
|
117
|
+
throw new Error(`group "${group}" does not exist — pass --repo <path> at least once to create it`);
|
|
118
|
+
}
|
|
119
|
+
const existing = findExistingPin(manifest, skillId);
|
|
120
|
+
if (existing) {
|
|
121
|
+
throw new Error(`skill "${skillId}" already pinned in ${existing}`);
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
...manifest,
|
|
125
|
+
project: { ...manifest.project, [group]: { repos, skills: [skillId] } },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (repos && repos.length > 0) {
|
|
130
|
+
throw new Error(`group "${group}" already exists — --repo is only used when creating a new group`);
|
|
131
|
+
}
|
|
132
|
+
const existing = findExistingPin(manifest, skillId);
|
|
133
|
+
if (existing) {
|
|
134
|
+
throw new Error(`skill "${skillId}" already pinned in ${existing}`);
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
...manifest,
|
|
138
|
+
project: { ...manifest.project, [group]: { ...existingGroup, skills: [...existingGroup.skills, skillId] } },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function unpinProject(manifest: Manifest, skillId: string, group: string): Manifest {
|
|
143
|
+
const existingGroup = manifest.project?.[group];
|
|
144
|
+
if (!existingGroup) {
|
|
145
|
+
throw new Error(`[project.${group}] does not exist`);
|
|
146
|
+
}
|
|
147
|
+
if (!existingGroup.skills.includes(skillId)) {
|
|
148
|
+
throw new Error(`skill "${skillId}" is not pinned in [project.${group}]`);
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
...manifest,
|
|
152
|
+
project: {
|
|
153
|
+
...manifest.project,
|
|
154
|
+
[group]: { ...existingGroup, skills: existingGroup.skills.filter((id) => id !== skillId) },
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
67
159
|
export interface ManifestValidationResult {
|
|
68
160
|
notes: string[];
|
|
69
161
|
}
|
|
70
162
|
|
|
71
163
|
const CORE_SKILL_LIMIT = 25;
|
|
72
164
|
|
|
73
|
-
|
|
165
|
+
function requireCoreVaultRoot(skillId: string, vaultPath: string, localVaultPaths: string[], location: string): void {
|
|
166
|
+
const root = resolveSkillRoot(skillId, vaultPath, localVaultPaths);
|
|
167
|
+
if (root === null) {
|
|
168
|
+
throw new Error(`${location} skill "${skillId}" does not exist in the vault`);
|
|
169
|
+
}
|
|
170
|
+
if (root !== vaultPath) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`${location} skill "${skillId}" only exists in a local vault path (${root}) — pins in the shared ` +
|
|
173
|
+
`manifest must be backed by the canonical vault_path (${vaultPath}) to stay portable across machines`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function validateManifest(
|
|
179
|
+
manifest: Manifest,
|
|
180
|
+
vaultPath: string,
|
|
181
|
+
localVaultPaths: string[] = [],
|
|
182
|
+
): ManifestValidationResult {
|
|
74
183
|
if (manifest.core.skills.length > CORE_SKILL_LIMIT) {
|
|
75
184
|
throw new Error(
|
|
76
185
|
`[core] has ${manifest.core.skills.length} skills, exceeding the limit of ${CORE_SKILL_LIMIT}`,
|
|
@@ -79,17 +188,22 @@ export function validateManifest(manifest: Manifest, vaultSkillIds: Set<string>)
|
|
|
79
188
|
|
|
80
189
|
const coreSet = new Set(manifest.core.skills);
|
|
81
190
|
for (const skillId of manifest.core.skills) {
|
|
82
|
-
|
|
83
|
-
|
|
191
|
+
requireCoreVaultRoot(skillId, vaultPath, localVaultPaths, "[core]");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const groupNames = new Set(Object.keys(manifest.project ?? {}));
|
|
195
|
+
for (const [targetName, target] of Object.entries(manifest.targets)) {
|
|
196
|
+
for (const groupName of target.project_groups) {
|
|
197
|
+
if (!groupNames.has(groupName)) {
|
|
198
|
+
throw new Error(`[targets.${targetName}] project_groups references undefined group "${groupName}"`);
|
|
199
|
+
}
|
|
84
200
|
}
|
|
85
201
|
}
|
|
86
202
|
|
|
87
203
|
const notes: string[] = [];
|
|
88
204
|
for (const [groupName, group] of Object.entries(manifest.project ?? {})) {
|
|
89
205
|
for (const skillId of group.skills) {
|
|
90
|
-
|
|
91
|
-
throw new Error(`[project.${groupName}] skill "${skillId}" does not exist in the vault`);
|
|
92
|
-
}
|
|
206
|
+
requireCoreVaultRoot(skillId, vaultPath, localVaultPaths, `[project.${groupName}]`);
|
|
93
207
|
if (coreSet.has(skillId)) {
|
|
94
208
|
throw new Error(`skill "${skillId}" appears in both [core] and [project.${groupName}]`);
|
|
95
209
|
}
|