agent-skillboard 0.1.2 → 0.2.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/README.md +154 -631
- package/docs/adapters.md +96 -96
- package/docs/ai-skill-routing-goal.md +112 -0
- package/docs/capabilities.md +6 -0
- package/docs/install.md +155 -105
- package/docs/plans/skillboard-variant-lifecycle-handoff.md +56 -0
- package/docs/policy-model.md +266 -214
- package/docs/positioning.md +94 -94
- package/docs/reference.md +349 -0
- package/docs/routing.md +85 -0
- package/docs/user-flow.md +75 -16
- package/docs/value-proof.md +190 -0
- package/docs/variant-lifecycle.md +86 -0
- package/docs/versioning.md +149 -138
- package/examples/multi-source-skills/anthropic/docx/SKILL.md +8 -8
- package/examples/multi-source-skills/anthropic/skill-creator/SKILL.md +8 -8
- package/examples/multi-source-skills/matt/grill-me/SKILL.md +8 -8
- package/examples/multi-source-skills/matt/tdd/SKILL.md +8 -8
- package/examples/multi-source-skills/omo/review-work/SKILL.md +8 -8
- package/examples/multi-source-skills/omo/ulw-plan/SKILL.md +8 -8
- package/examples/multi-source-skills/private/tdd-work-continuity/SKILL.md +8 -8
- package/examples/multi-source-skills/private/workflow-router/SKILL.md +8 -8
- package/examples/multi-source-skills/wshobson/python-testing/SKILL.md +8 -8
- package/examples/multi-source-skills/wshobson/security-review/SKILL.md +8 -8
- package/examples/skills/grill-me/SKILL.md +9 -9
- package/examples/skills/grill-with-docs/SKILL.md +9 -9
- package/examples/skills/requirement-intake/SKILL.md +9 -9
- package/examples/skills/tdd/SKILL.md +8 -8
- package/package.json +7 -3
- package/src/advisor/guidance.mjs +232 -0
- package/src/advisor/schema.mjs +2 -0
- package/src/advisor/skills.mjs +2 -0
- package/src/advisor.mjs +36 -7
- package/src/brief-cli.mjs +6 -5
- package/src/brief-renderer.mjs +225 -8
- package/src/cli.mjs +574 -27
- package/src/config-helpers.mjs +34 -18
- package/src/conflicts.mjs +70 -0
- package/src/control/can-use-guard.mjs +8 -3
- package/src/control/skill-crud.mjs +142 -0
- package/src/control/skill-variants.mjs +221 -0
- package/src/control/source-trust.mjs +1 -0
- package/src/control/variant-files.mjs +265 -0
- package/src/control/variant-lifecycle-config.mjs +156 -0
- package/src/control/variant-reset.mjs +171 -0
- package/src/control/variant-status.mjs +75 -0
- package/src/control.mjs +13 -1
- package/src/domain/rules/skills.mjs +60 -0
- package/src/domain/rules/workflows.mjs +13 -0
- package/src/impact.mjs +21 -12
- package/src/index.mjs +13 -1
- package/src/lifecycle-cli.mjs +18 -7
- package/src/lifecycle-content.mjs +29 -22
- package/src/route.mjs +537 -0
- package/src/source-verification.mjs +7 -3
- package/src/workspace.mjs +141 -43
- package/tsconfig.lsp.json +1 -1
package/src/route.mjs
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { command } from "./advisor/action-core.mjs";
|
|
2
|
+
import { canUseSkill } from "./control/can-use-guard.mjs";
|
|
3
|
+
|
|
4
|
+
const HIGH_CONFIDENCE = 4;
|
|
5
|
+
const MEDIUM_CONFIDENCE = 2;
|
|
6
|
+
const DEFAULT_CONFIG_PATH = "skillboard.config.yaml";
|
|
7
|
+
const DEFAULT_SKILLS_ROOT = "skills";
|
|
8
|
+
const ROUTE_STOP_WORDS = new Set([
|
|
9
|
+
"a",
|
|
10
|
+
"an",
|
|
11
|
+
"and",
|
|
12
|
+
"are",
|
|
13
|
+
"as",
|
|
14
|
+
"at",
|
|
15
|
+
"be",
|
|
16
|
+
"been",
|
|
17
|
+
"before",
|
|
18
|
+
"being",
|
|
19
|
+
"by",
|
|
20
|
+
"for",
|
|
21
|
+
"from",
|
|
22
|
+
"in",
|
|
23
|
+
"is",
|
|
24
|
+
"it",
|
|
25
|
+
"of",
|
|
26
|
+
"on",
|
|
27
|
+
"or",
|
|
28
|
+
"that",
|
|
29
|
+
"the",
|
|
30
|
+
"these",
|
|
31
|
+
"this",
|
|
32
|
+
"those",
|
|
33
|
+
"to",
|
|
34
|
+
"was",
|
|
35
|
+
"were",
|
|
36
|
+
"with",
|
|
37
|
+
"without",
|
|
38
|
+
"you",
|
|
39
|
+
"your"
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
export function routeSkill(workspace, options) {
|
|
43
|
+
const intent = options.intent.trim();
|
|
44
|
+
if (intent.length === 0) {
|
|
45
|
+
throw new Error("Usage: skillboard route <intent> --workflow <name>");
|
|
46
|
+
}
|
|
47
|
+
const workflow = workspace.workflows.find((candidate) => candidate.name === options.workflow);
|
|
48
|
+
if (workflow === undefined) {
|
|
49
|
+
throw new Error(`Unknown workflow: ${options.workflow}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const intentTokens = tokensFor(intent);
|
|
53
|
+
const candidates = capabilityRouteCandidates(workspace, workflow)
|
|
54
|
+
.map((candidate) => scoreCandidate(workspace, candidate, intent, intentTokens))
|
|
55
|
+
.sort((left, right) => right.score - left.score || left.capability.localeCompare(right.capability));
|
|
56
|
+
const best = candidates.find((candidate) => candidate.score > 0);
|
|
57
|
+
if (best === undefined) {
|
|
58
|
+
const skillCandidates = skillRouteCandidates(workspace, workflow, intent, intentTokens);
|
|
59
|
+
const skillBest = skillCandidates.find((candidate) => candidate.score > 0 && candidate.allowed)
|
|
60
|
+
?? skillCandidates.find((candidate) => candidate.score > 0);
|
|
61
|
+
return skillBest === undefined
|
|
62
|
+
? noRoute({ intent, workflow, candidates, skillCandidates, workspace })
|
|
63
|
+
: skillRoute({ intent, workflow, candidate: skillBest, candidates, skillCandidates, options, workspace });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const routedSkills = best.skill_ids.map((skillId, index) => ({
|
|
67
|
+
skill: skillId,
|
|
68
|
+
role: index === 0 ? "preferred" : "fallback",
|
|
69
|
+
guard: canUseSkill(workspace, skillId, workflow.name)
|
|
70
|
+
}));
|
|
71
|
+
const recommended = selectedRouteSkill(routedSkills);
|
|
72
|
+
const fallbackSkills = allowedFallbackSkills(routedSkills, recommended);
|
|
73
|
+
const postUsePolicySuggestion = postUsePolicySuggestionForCapabilityRoute({
|
|
74
|
+
matchedCapability: best.capability,
|
|
75
|
+
routeMatch: best,
|
|
76
|
+
recommended,
|
|
77
|
+
routedSkills,
|
|
78
|
+
workflowName: workflow.name,
|
|
79
|
+
options
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
intent,
|
|
85
|
+
workflow: workflow.name,
|
|
86
|
+
matched_capability: best.capability,
|
|
87
|
+
matched_skill: null,
|
|
88
|
+
match_source: "capability",
|
|
89
|
+
confidence: confidenceFor(best.score),
|
|
90
|
+
matched_terms: best.matched_tokens,
|
|
91
|
+
recommendation_reason: recommendationReason({
|
|
92
|
+
match: `Matched capability ${best.capability}`,
|
|
93
|
+
matchedFields: best.matched_fields,
|
|
94
|
+
matchedTerms: best.matched_tokens,
|
|
95
|
+
skill: recommended?.skill ?? null,
|
|
96
|
+
guard: recommended?.guard ?? null
|
|
97
|
+
}),
|
|
98
|
+
recommended_skill: recommended?.skill ?? null,
|
|
99
|
+
fallback_skills: fallbackSkills,
|
|
100
|
+
route_candidates: routedSkills.map((entry) => routeCandidate(entry, entry.skill === recommended?.skill)),
|
|
101
|
+
guard_command: recommended === null ? null : guardCommand(recommended.skill, workflow.name, options),
|
|
102
|
+
guard: recommended?.guard ?? null,
|
|
103
|
+
usage_disclosure: recommended?.guard.allowed === true ? usageDisclosure(recommended.skill) : null,
|
|
104
|
+
post_use_policy_suggestion: postUsePolicySuggestion,
|
|
105
|
+
possible_skills: possibleWorkflowSkills(workspace, workflow),
|
|
106
|
+
candidates
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function noRoute({ intent, workflow, candidates, skillCandidates, workspace }) {
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
intent,
|
|
114
|
+
workflow: workflow.name,
|
|
115
|
+
matched_capability: null,
|
|
116
|
+
matched_skill: null,
|
|
117
|
+
match_source: "none",
|
|
118
|
+
confidence: "none",
|
|
119
|
+
matched_terms: [],
|
|
120
|
+
recommendation_reason: "No workflow capability or skill metadata matched this request. Ask a clarifying question before choosing a skill.",
|
|
121
|
+
recommended_skill: null,
|
|
122
|
+
fallback_skills: [],
|
|
123
|
+
route_candidates: [],
|
|
124
|
+
guard_command: null,
|
|
125
|
+
guard: null,
|
|
126
|
+
usage_disclosure: null,
|
|
127
|
+
post_use_policy_suggestion: null,
|
|
128
|
+
possible_skills: possibleWorkflowSkills(workspace, workflow),
|
|
129
|
+
candidates,
|
|
130
|
+
skill_candidates: skillCandidates
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function skillRoute({ intent, workflow, candidate, candidates, skillCandidates, options, workspace }) {
|
|
135
|
+
const routeCandidates = skillCandidates
|
|
136
|
+
.filter((entry) => entry.score > 0)
|
|
137
|
+
.map((entry) => ({
|
|
138
|
+
skill: entry.skill_id,
|
|
139
|
+
role: entry.skill_id === candidate.skill_id ? "matched" : "alternative",
|
|
140
|
+
guard: canUseSkill(workspace, entry.skill_id, workflow.name)
|
|
141
|
+
}));
|
|
142
|
+
const selectedCandidate = routeCandidates.find((entry) => entry.skill === candidate.skill_id);
|
|
143
|
+
const guard = selectedCandidate?.guard ?? canUseSkill(workspace, candidate.skill_id, workflow.name);
|
|
144
|
+
const fallbackSkills = allowedFallbackSkills(routeCandidates, selectedCandidate);
|
|
145
|
+
return {
|
|
146
|
+
ok: true,
|
|
147
|
+
intent,
|
|
148
|
+
workflow: workflow.name,
|
|
149
|
+
matched_capability: null,
|
|
150
|
+
matched_skill: candidate.skill_id,
|
|
151
|
+
match_source: "skill-metadata",
|
|
152
|
+
confidence: confidenceFor(candidate.score),
|
|
153
|
+
matched_terms: candidate.matched_tokens,
|
|
154
|
+
recommendation_reason: recommendationReason({
|
|
155
|
+
match: `Matched workflow skill metadata for ${candidate.skill_id}`,
|
|
156
|
+
matchedFields: candidate.matched_fields,
|
|
157
|
+
matchedTerms: candidate.matched_tokens,
|
|
158
|
+
skill: candidate.skill_id,
|
|
159
|
+
guard
|
|
160
|
+
}),
|
|
161
|
+
recommended_skill: candidate.skill_id,
|
|
162
|
+
fallback_skills: fallbackSkills,
|
|
163
|
+
route_candidates: routeCandidates.map((entry) => routeCandidate(entry, entry.skill === candidate.skill_id)),
|
|
164
|
+
guard_command: guardCommand(candidate.skill_id, workflow.name, options),
|
|
165
|
+
guard,
|
|
166
|
+
usage_disclosure: guard.allowed ? usageDisclosure(candidate.skill_id) : null,
|
|
167
|
+
post_use_policy_suggestion: null,
|
|
168
|
+
possible_skills: possibleWorkflowSkills(workspace, workflow),
|
|
169
|
+
candidates,
|
|
170
|
+
skill_candidates: skillCandidates
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function routeCandidate(entry, selected) {
|
|
175
|
+
return {
|
|
176
|
+
skill: entry.skill,
|
|
177
|
+
role: entry.role,
|
|
178
|
+
selected,
|
|
179
|
+
guard_allowed: entry.guard.allowed,
|
|
180
|
+
guard_reasons: entry.guard.reasons,
|
|
181
|
+
guard_roles: entry.guard.roles,
|
|
182
|
+
capability_roles: entry.guard.capabilityRoles
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function usageDisclosure(skillId) {
|
|
187
|
+
return {
|
|
188
|
+
confirmation_required: false,
|
|
189
|
+
start: `State at the start that ${skillId} is being used for this request.`,
|
|
190
|
+
finish: `State at completion that ${skillId} was used.`,
|
|
191
|
+
start_message: `I will use ${skillId} for this request.`,
|
|
192
|
+
finish_message: `I used ${skillId} for this request.`,
|
|
193
|
+
guard: "Run the guard automatically immediately before invocation. Ask the user only if the guard denies use or a policy-changing action is needed."
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function selectedRouteSkill(routedSkills) {
|
|
198
|
+
return routedSkills.find((entry) => entry.guard.allowed) ?? routedSkills[0] ?? null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function allowedFallbackSkills(routedSkills, recommended) {
|
|
202
|
+
return routedSkills
|
|
203
|
+
.filter((entry) => entry.guard.allowed && entry.skill !== recommended?.skill)
|
|
204
|
+
.map((entry) => entry.skill);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function postUsePolicySuggestionForCapabilityRoute({ matchedCapability, routeMatch, recommended, routedSkills, workflowName, options }) {
|
|
208
|
+
return postUsePolicySuggestionForDeniedPreferredFallback({
|
|
209
|
+
matchedCapability,
|
|
210
|
+
recommended,
|
|
211
|
+
routedSkills,
|
|
212
|
+
workflowName,
|
|
213
|
+
options
|
|
214
|
+
}) ?? postUsePolicySuggestionForAllowedAmbiguity({
|
|
215
|
+
matchedCapability,
|
|
216
|
+
routeMatch,
|
|
217
|
+
recommended,
|
|
218
|
+
routedSkills,
|
|
219
|
+
workflowName,
|
|
220
|
+
options
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function postUsePolicySuggestionForDeniedPreferredFallback({ matchedCapability, recommended, routedSkills, workflowName, options }) {
|
|
225
|
+
if (recommended === null || recommended.role !== "fallback" || recommended.guard.allowed !== true) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
const preferred = routedSkills.find((entry) => entry.role === "preferred");
|
|
229
|
+
if (preferred === undefined || preferred.guard.allowed === true) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
timing: "after_use",
|
|
234
|
+
mode: "ask_after_use",
|
|
235
|
+
reason: `SkillBoard selected fallback ${recommended.skill} because preferred skill ${preferred.skill} is denied. After completing the task, ask whether to remember ${recommended.skill} as the preferred skill for ${matchedCapability} in ${workflowName}.`,
|
|
236
|
+
question: `Should I remember ${recommended.skill} as the preferred skill for similar ${matchedCapability} requests in ${workflowName}?`,
|
|
237
|
+
requires_confirmation: true,
|
|
238
|
+
suggested_policy: {
|
|
239
|
+
kind: "prefer-skill",
|
|
240
|
+
skill: recommended.skill,
|
|
241
|
+
workflow: workflowName,
|
|
242
|
+
capability: matchedCapability,
|
|
243
|
+
command_hint: command([
|
|
244
|
+
"skillboard", "prefer", recommended.skill,
|
|
245
|
+
"--workflow", workflowName,
|
|
246
|
+
"--capability", matchedCapability,
|
|
247
|
+
"--config", routeConfigPath(options),
|
|
248
|
+
"--skills", routeSkillsRoot(options)
|
|
249
|
+
]).display
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function postUsePolicySuggestionForAllowedAmbiguity({ matchedCapability, routeMatch, recommended, routedSkills, workflowName, options }) {
|
|
255
|
+
if (recommended === null || recommended.guard.allowed !== true || routeMatch.required_by_workflow) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
const allowedSkills = routedSkills.filter((entry) => entry.guard.allowed);
|
|
259
|
+
if (allowedSkills.length < 2) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
timing: "after_use",
|
|
264
|
+
mode: "ask_after_use",
|
|
265
|
+
reason: `SkillBoard found multiple allowed skills for ${matchedCapability} and selected ${recommended.skill}. After completing the task, ask whether to remember ${recommended.skill} as the preferred skill for ${matchedCapability} in ${workflowName} to reduce future ambiguity.`,
|
|
266
|
+
question: `Should I remember ${recommended.skill} as the preferred skill for similar ${matchedCapability} requests in ${workflowName}?`,
|
|
267
|
+
requires_confirmation: true,
|
|
268
|
+
suggested_policy: {
|
|
269
|
+
kind: "prefer-skill",
|
|
270
|
+
skill: recommended.skill,
|
|
271
|
+
workflow: workflowName,
|
|
272
|
+
capability: matchedCapability,
|
|
273
|
+
command_hint: command([
|
|
274
|
+
"skillboard", "prefer", recommended.skill,
|
|
275
|
+
"--workflow", workflowName,
|
|
276
|
+
"--capability", matchedCapability,
|
|
277
|
+
"--config", routeConfigPath(options),
|
|
278
|
+
"--skills", routeSkillsRoot(options)
|
|
279
|
+
]).display
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function capabilityRouteCandidates(workspace, workflow) {
|
|
285
|
+
const workflowSkillIds = new Set(workflowBoundSkillIds(workspace, workflow));
|
|
286
|
+
return workspace.capabilities.flatMap((capability) => {
|
|
287
|
+
const requirement = workflow.requiredCapabilities.find((candidate) => candidate.name === capability.name);
|
|
288
|
+
const requiredSkillIds = uniqueStrings([
|
|
289
|
+
requirement?.preferred ?? "",
|
|
290
|
+
...(requirement?.fallback ?? [])
|
|
291
|
+
]);
|
|
292
|
+
const configuredSkillIds = uniqueStrings([
|
|
293
|
+
...requiredSkillIds,
|
|
294
|
+
capability.canonical,
|
|
295
|
+
...capability.alternatives
|
|
296
|
+
]);
|
|
297
|
+
const boundSkillIds = configuredSkillIds.filter((skillId) => workflowSkillIds.has(skillId));
|
|
298
|
+
if (requirement === undefined && boundSkillIds.length === 0) {
|
|
299
|
+
return [];
|
|
300
|
+
}
|
|
301
|
+
const skillIds = requirement === undefined ? boundSkillIds : uniqueStrings([...requiredSkillIds, ...boundSkillIds]);
|
|
302
|
+
const skills = skillIds
|
|
303
|
+
.map((skillId) => workspace.skills.find((skill) => skill.id === skillId))
|
|
304
|
+
.filter((skill) => skill !== undefined);
|
|
305
|
+
return [{
|
|
306
|
+
capability: capability.name,
|
|
307
|
+
default_policy: capability.defaultPolicy,
|
|
308
|
+
skill_ids: skillIds,
|
|
309
|
+
skills,
|
|
310
|
+
required_by_workflow: requirement !== undefined
|
|
311
|
+
}];
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function workflowBoundSkillIds(workspace, workflow) {
|
|
316
|
+
return uniqueStrings([
|
|
317
|
+
...workflow.activeSkills,
|
|
318
|
+
...workflow.requiredCapabilities.flatMap((capability) => [capability.preferred, ...capability.fallback]),
|
|
319
|
+
...workspace.skills.filter((skill) => skill.invocation === "global-auto").map((skill) => skill.id)
|
|
320
|
+
]);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function scoreCandidate(workspace, candidate, intent, intentTokens) {
|
|
324
|
+
const capabilityTokens = tokensFor(candidate.capability);
|
|
325
|
+
let score = phraseMatchScore(intent, candidate.capability);
|
|
326
|
+
const matched_tokens = [];
|
|
327
|
+
const matched_fields = [];
|
|
328
|
+
if (score > 0) {
|
|
329
|
+
matched_fields.push("capability name");
|
|
330
|
+
}
|
|
331
|
+
for (const token of capabilityTokens) {
|
|
332
|
+
if (intentTokens.has(token)) {
|
|
333
|
+
score += 2;
|
|
334
|
+
matched_tokens.push(token);
|
|
335
|
+
matched_fields.push("capability name");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const metadataMatch = scoreMetadataTerms(
|
|
339
|
+
candidate.skills.flatMap((skill) => skillMetadataTerms(workspace, skill.id, 1)),
|
|
340
|
+
intentTokens
|
|
341
|
+
);
|
|
342
|
+
score += metadataMatch.score;
|
|
343
|
+
matched_tokens.push(...metadataMatch.matchedTokens);
|
|
344
|
+
matched_fields.push(...metadataMatch.matchedFields);
|
|
345
|
+
return {
|
|
346
|
+
capability: candidate.capability,
|
|
347
|
+
confidence: confidenceFor(score),
|
|
348
|
+
score,
|
|
349
|
+
matched_tokens: uniqueStrings(matched_tokens).sort(),
|
|
350
|
+
matched_fields: uniqueStrings(matched_fields),
|
|
351
|
+
recommended_skill: candidate.skill_ids[0] ?? null,
|
|
352
|
+
fallback_skills: candidate.skill_ids.slice(1),
|
|
353
|
+
skill_ids: candidate.skill_ids,
|
|
354
|
+
required_by_workflow: candidate.required_by_workflow
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function skillRouteCandidates(workspace, workflow, intent, intentTokens) {
|
|
359
|
+
return possibleWorkflowSkills(workspace, workflow)
|
|
360
|
+
.map((skill) => scoreSkillCandidate(workspace, skill, intent, intentTokens))
|
|
361
|
+
.sort((left, right) => right.score - left.score || left.skill_id.localeCompare(right.skill_id));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function scoreSkillCandidate(workspace, possibleSkill, intent, intentTokens) {
|
|
365
|
+
const skill = workspace.skills.find((candidate) => candidate.id === possibleSkill.id);
|
|
366
|
+
let score = phraseMatchScore(intent, possibleSkill.id);
|
|
367
|
+
const matched_fields = score > 0 ? ["skill id"] : [];
|
|
368
|
+
const metadataMatch = scoreMetadataTerms(skillMetadataTerms(workspace, possibleSkill.id, 2), intentTokens);
|
|
369
|
+
score += metadataMatch.score;
|
|
370
|
+
matched_fields.push(...metadataMatch.matchedFields);
|
|
371
|
+
const matched_tokens = metadataMatch.matchedTokens;
|
|
372
|
+
return {
|
|
373
|
+
skill_id: possibleSkill.id,
|
|
374
|
+
category: possibleSkill.category,
|
|
375
|
+
allowed: possibleSkill.allowed,
|
|
376
|
+
confidence: confidenceFor(score),
|
|
377
|
+
score,
|
|
378
|
+
matched_tokens: uniqueStrings(matched_tokens).sort(),
|
|
379
|
+
matched_fields: uniqueStrings(matched_fields)
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function scoreMetadataTerms(terms, intentTokens) {
|
|
384
|
+
let score = 0;
|
|
385
|
+
const matchedTokens = [];
|
|
386
|
+
const matchedFields = [];
|
|
387
|
+
const seenTokens = new Set();
|
|
388
|
+
for (const term of terms) {
|
|
389
|
+
for (const token of tokensFor(term.value)) {
|
|
390
|
+
if (!intentTokens.has(token)) {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
matchedFields.push(term.label);
|
|
394
|
+
const matchedToken = canonicalRouteToken(token);
|
|
395
|
+
if (seenTokens.has(matchedToken)) {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
seenTokens.add(matchedToken);
|
|
399
|
+
score += term.weight;
|
|
400
|
+
matchedTokens.push(matchedToken);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
score,
|
|
405
|
+
matchedTokens,
|
|
406
|
+
matchedFields: uniqueStrings(matchedFields)
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function skillMetadataTerms(workspace, skillId, weight) {
|
|
411
|
+
const skill = workspace.skills.find((candidate) => candidate.id === skillId);
|
|
412
|
+
const installed = installedSkillFor(workspace, skillId);
|
|
413
|
+
return [
|
|
414
|
+
{ label: "skill id", value: skill?.id ?? skillId, weight },
|
|
415
|
+
{ label: "skill path", value: skill?.path ?? installed?.path ?? "", weight },
|
|
416
|
+
{ label: "category", value: skill?.category ?? "", weight },
|
|
417
|
+
{ label: "SKILL.md name", value: installed?.name ?? "", weight },
|
|
418
|
+
{ label: "SKILL.md description", value: installed?.description ?? "", weight }
|
|
419
|
+
];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function installedSkillFor(workspace, skillId) {
|
|
423
|
+
const skill = workspace.skills.find((candidate) => candidate.id === skillId);
|
|
424
|
+
return workspace.installedSkills.find((installed) => installed.id === skillId || installed.path === skill?.path);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function recommendationReason({ match, matchedFields, matchedTerms, skill, guard }) {
|
|
428
|
+
const fieldText = matchedFields.length === 0
|
|
429
|
+
? ""
|
|
430
|
+
: ` through ${matchedFields.join(", ")}`;
|
|
431
|
+
const termText = matchedTerms.length === 0
|
|
432
|
+
? ""
|
|
433
|
+
: ` (${matchedTerms.join(", ")})`;
|
|
434
|
+
if (skill === null || guard === null) {
|
|
435
|
+
return `${match}${fieldText}${termText}, but no workflow-bound skill is available.`;
|
|
436
|
+
}
|
|
437
|
+
if (guard.allowed) {
|
|
438
|
+
return `${match}${fieldText}${termText}; recommended ${skill} because the guard allows ${skill}.`;
|
|
439
|
+
}
|
|
440
|
+
const reason = guard.reasons[0] ?? "the guard did not provide a reason";
|
|
441
|
+
return `${match}${fieldText}${termText}; recommended ${skill}, but the guard currently denies it: ${reason}`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function phraseMatchScore(intent, capability) {
|
|
445
|
+
const normalizedIntent = phraseKey(intent);
|
|
446
|
+
const normalizedCapability = phraseKey(capability);
|
|
447
|
+
if (normalizedIntent.length === 0 || normalizedCapability.length === 0) {
|
|
448
|
+
return 0;
|
|
449
|
+
}
|
|
450
|
+
return normalizedIntent.includes(normalizedCapability) || normalizedCapability.includes(normalizedIntent) ? 4 : 0;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function possibleWorkflowSkills(workspace, workflow) {
|
|
454
|
+
const ids = workflowBoundSkillIds(workspace, workflow);
|
|
455
|
+
return ids.map((skillId) => {
|
|
456
|
+
const skill = workspace.skills.find((candidate) => candidate.id === skillId);
|
|
457
|
+
const guard = canUseSkill(workspace, skillId, workflow.name);
|
|
458
|
+
return {
|
|
459
|
+
id: skillId,
|
|
460
|
+
status: skill?.status ?? null,
|
|
461
|
+
invocation: skill?.invocation ?? null,
|
|
462
|
+
category: skill?.category ?? null,
|
|
463
|
+
roles: guard.roles,
|
|
464
|
+
capability_roles: guard.capabilityRoles,
|
|
465
|
+
allowed: guard.allowed
|
|
466
|
+
};
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function guardCommand(skillId, workflowName, options) {
|
|
471
|
+
return command([
|
|
472
|
+
"skillboard", "guard", "use", skillId,
|
|
473
|
+
"--workflow", workflowName,
|
|
474
|
+
"--config", routeConfigPath(options),
|
|
475
|
+
"--skills", routeSkillsRoot(options)
|
|
476
|
+
]).display;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function routeConfigPath(options) {
|
|
480
|
+
return options.configPath ?? DEFAULT_CONFIG_PATH;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function routeSkillsRoot(options) {
|
|
484
|
+
return options.skillsRoot ?? DEFAULT_SKILLS_ROOT;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function confidenceFor(score) {
|
|
488
|
+
if (score >= HIGH_CONFIDENCE) {
|
|
489
|
+
return "high";
|
|
490
|
+
}
|
|
491
|
+
if (score >= MEDIUM_CONFIDENCE) {
|
|
492
|
+
return "medium";
|
|
493
|
+
}
|
|
494
|
+
if (score > 0) {
|
|
495
|
+
return "low";
|
|
496
|
+
}
|
|
497
|
+
return "none";
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function tokensFor(value) {
|
|
501
|
+
return new Set(String(value)
|
|
502
|
+
.toLowerCase()
|
|
503
|
+
.split(/[^a-z0-9]+/u)
|
|
504
|
+
.flatMap(tokenForms)
|
|
505
|
+
.filter(isRouteToken));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function tokenForms(token) {
|
|
509
|
+
const singular = singularRouteToken(token);
|
|
510
|
+
return singular === token ? [token] : [token, singular];
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function canonicalRouteToken(token) {
|
|
514
|
+
return singularRouteToken(token);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function singularRouteToken(token) {
|
|
518
|
+
if (token.length > 4 && token.endsWith("ies")) {
|
|
519
|
+
return `${token.slice(0, -3)}y`;
|
|
520
|
+
}
|
|
521
|
+
if (token.length > 3 && token.endsWith("s") && !/(?:ss|us|is)$/u.test(token)) {
|
|
522
|
+
return token.slice(0, -1);
|
|
523
|
+
}
|
|
524
|
+
return token;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function isRouteToken(token) {
|
|
528
|
+
return token.length > 1 && !ROUTE_STOP_WORDS.has(token);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function phraseKey(value) {
|
|
532
|
+
return [...tokensFor(value)].join(" ");
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function uniqueStrings(values) {
|
|
536
|
+
return [...new Set(values.filter((value) => typeof value === "string" && value.length > 0))];
|
|
537
|
+
}
|
|
@@ -233,6 +233,12 @@ function uniquePaths(paths) {
|
|
|
233
233
|
return [...new Set(paths)];
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
export async function skillContentDigest(skillFilePath) {
|
|
237
|
+
const hash = createHash("sha256");
|
|
238
|
+
hash.update(await readFile(skillFilePath));
|
|
239
|
+
return `${DIGEST_PREFIX}${hash.digest("hex")}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
236
242
|
async function skillContentDigests(workspace, skillsRoot) {
|
|
237
243
|
const digests = new Map();
|
|
238
244
|
if (skillsRoot === undefined) {
|
|
@@ -241,9 +247,7 @@ async function skillContentDigests(workspace, skillsRoot) {
|
|
|
241
247
|
for (const skill of workspace.skills) {
|
|
242
248
|
const skillPath = join(skillsRoot, skill.path, "SKILL.md");
|
|
243
249
|
try {
|
|
244
|
-
|
|
245
|
-
hash.update(await readFile(skillPath));
|
|
246
|
-
digests.set(skill.id, `${DIGEST_PREFIX}${hash.digest("hex")}`);
|
|
250
|
+
digests.set(skill.id, await skillContentDigest(skillPath));
|
|
247
251
|
} catch {
|
|
248
252
|
digests.set(skill.id, null);
|
|
249
253
|
}
|