agent-skillboard 0.2.14 → 0.2.16

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/src/route.mjs CHANGED
@@ -1,43 +1,16 @@
1
- import { command } from "./advisor/action-core.mjs";
2
1
  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
- ]);
2
+ import {
3
+ allowedFallbackSkills,
4
+ guardCommand,
5
+ overlapResolutionForRoute,
6
+ policyMemoryForRoute,
7
+ postUsePolicySuggestionForCapabilityRoute,
8
+ recommendationReason,
9
+ routeCandidate,
10
+ selectedRouteSkill,
11
+ usageDisclosure
12
+ } from "./route-advisory.mjs";
13
+ import { confidenceFor, possibleWorkflowSkills, selectRoute } from "./route-selection.mjs";
41
14
 
42
15
  export function routeSkill(workspace, options) {
43
16
  const intent = options.intent.trim();
@@ -49,15 +22,17 @@ export function routeSkill(workspace, options) {
49
22
  throw new Error(`Unknown workflow: ${options.workflow}`);
50
23
  }
51
24
 
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);
25
+ const {
26
+ candidates,
27
+ skillCandidates,
28
+ explicitSkill,
29
+ best,
30
+ skillBest
31
+ } = selectRoute(workspace, workflow, intent);
32
+ if (explicitSkill !== undefined) {
33
+ return skillRoute({ intent, workflow, candidate: explicitSkill, candidates, skillCandidates, options, workspace });
34
+ }
57
35
  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
36
  return skillBest === undefined
62
37
  ? noRoute({ intent, workflow, candidates, skillCandidates, workspace })
63
38
  : skillRoute({ intent, workflow, candidate: skillBest, candidates, skillCandidates, options, workspace });
@@ -70,6 +45,19 @@ export function routeSkill(workspace, options) {
70
45
  }));
71
46
  const recommended = selectedRouteSkill(routedSkills);
72
47
  const fallbackSkills = allowedFallbackSkills(routedSkills, recommended);
48
+ const overlapResolution = overlapResolutionForRoute({
49
+ matchedCapability: best.capability,
50
+ recommended,
51
+ routedSkills,
52
+ workflowName: workflow.name
53
+ });
54
+ const policyMemory = policyMemoryForRoute({
55
+ matchedCapability: best.capability,
56
+ routeMatch: best,
57
+ recommended,
58
+ routedSkills,
59
+ workflowName: workflow.name
60
+ });
73
61
  const postUsePolicySuggestion = postUsePolicySuggestionForCapabilityRoute({
74
62
  matchedCapability: best.capability,
75
63
  routeMatch: best,
@@ -98,9 +86,11 @@ export function routeSkill(workspace, options) {
98
86
  recommended_skill: recommended?.skill ?? null,
99
87
  fallback_skills: fallbackSkills,
100
88
  route_candidates: routedSkills.map((entry) => routeCandidate(entry, entry.skill === recommended?.skill)),
89
+ overlap_resolution: overlapResolution,
90
+ policy_memory: policyMemory,
101
91
  guard_command: recommended === null ? null : guardCommand(recommended.skill, workflow.name, options),
102
92
  guard: recommended?.guard ?? null,
103
- usage_disclosure: recommended?.guard.allowed === true ? usageDisclosure(recommended.skill) : null,
93
+ usage_disclosure: recommended?.guard.allowed === true ? usageDisclosure(recommended.skill, policyMemory) : null,
104
94
  post_use_policy_suggestion: postUsePolicySuggestion,
105
95
  possible_skills: possibleWorkflowSkills(workspace, workflow),
106
96
  candidates
@@ -121,6 +111,8 @@ function noRoute({ intent, workflow, candidates, skillCandidates, workspace }) {
121
111
  recommended_skill: null,
122
112
  fallback_skills: [],
123
113
  route_candidates: [],
114
+ overlap_resolution: null,
115
+ policy_memory: null,
124
116
  guard_command: null,
125
117
  guard: null,
126
118
  usage_disclosure: null,
@@ -142,6 +134,12 @@ function skillRoute({ intent, workflow, candidate, candidates, skillCandidates,
142
134
  const selectedCandidate = routeCandidates.find((entry) => entry.skill === candidate.skill_id);
143
135
  const guard = selectedCandidate?.guard ?? canUseSkill(workspace, candidate.skill_id, workflow.name);
144
136
  const fallbackSkills = allowedFallbackSkills(routeCandidates, selectedCandidate);
137
+ const overlapResolution = overlapResolutionForRoute({
138
+ matchedCapability: candidate.skill_id,
139
+ recommended: selectedCandidate ?? null,
140
+ routedSkills: routeCandidates,
141
+ workflowName: workflow.name
142
+ });
145
143
  return {
146
144
  ok: true,
147
145
  intent,
@@ -161,6 +159,8 @@ function skillRoute({ intent, workflow, candidate, candidates, skillCandidates,
161
159
  recommended_skill: candidate.skill_id,
162
160
  fallback_skills: fallbackSkills,
163
161
  route_candidates: routeCandidates.map((entry) => routeCandidate(entry, entry.skill === candidate.skill_id)),
162
+ overlap_resolution: overlapResolution,
163
+ policy_memory: null,
164
164
  guard_command: guardCommand(candidate.skill_id, workflow.name, options),
165
165
  guard,
166
166
  usage_disclosure: guard.allowed ? usageDisclosure(candidate.skill_id) : null,
@@ -170,368 +170,3 @@ function skillRoute({ intent, workflow, candidate, candidates, skillCandidates,
170
170
  skill_candidates: skillCandidates
171
171
  };
172
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
- }