@selesai/code 0.13.14 → 0.13.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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to `@selesai/code` will be documented in this file.
4
4
 
5
+ ## [0.13.16] - 2026-09-06
6
+
7
+ ### Fixed
8
+ - **Capability gateway inline-skill routing.** `$skill` references expanded by inline skills are no longer re-recommended through `capability_skill_show`; embedded `<skill>` blocks are excluded from prompt routing.
9
+ - **TokenIn search backend URL.** A custom `baseUrl` ending in `/v1` no longer produces a doubled `/v1/v1/search/…` path, and accounts with a blank `apiKey` are now treated as unconfigured instead of triggering doomed requests.
10
+ - **TokenIn search fallback typing.** Search fallback metadata can now reference any provider, matching actual failover behavior.
11
+
12
+ ## [0.13.15] - 2026-09-06
13
+
14
+ ### Added
15
+ - **Progressive capability gateway.** Optional extension tools and skills now stay out of the default model context while remaining discoverable through `capability_catalog`, `capability_discover`, and `capability_skill_show`. Built-in tools are unchanged, and the gateway can be disabled with `SELESAI_CAPABILITY_GATEWAY=0`.
16
+
17
+ ### Fixed
18
+ - **Capability catalog metadata.** Disabled skills now recover their frontmatter descriptions, and catalog results are no longer capped at 20 entries.
19
+ - **Zentui stale-session updates.** Timer-driven working-line updates now fail open when a captured extension context becomes stale after session replacement or reload.
20
+
5
21
  ## [0.13.13] - 2026-09-04
6
22
 
7
23
  ### Added
@@ -19,6 +19,8 @@ function createMockSession({ enabled, threshold = 128_000, tokens, mode = "tui",
19
19
  const resourceLoader = {
20
20
  getExtensions: () => ({ extensions: [], errors: [], runtime: { flagValues: new Map(), pendingProviderRegistrations: [], pendingNativeProviderRegistrations: [], assertActive: () => { } } }),
21
21
  getSkills: () => ({ skills: [], diagnostics: [] }),
22
+ getPromptSkills: () => [],
23
+ setSkillsIndexFilter: () => { },
22
24
  getPrompts: () => ({ prompts: [], diagnostics: [] }),
23
25
  getThemes: () => ({ themes: [], diagnostics: [] }),
24
26
  getAgents: () => ({ agents: [], diagnostics: [] }),
@@ -14,6 +14,8 @@ function createMockSession() {
14
14
  const resourceLoader = {
15
15
  getExtensions: () => ({ extensions: [], errors: [], runtime: { flagValues: new Map(), pendingProviderRegistrations: [], pendingNativeProviderRegistrations: [], assertActive: () => { } } }),
16
16
  getSkills: () => ({ skills: [], diagnostics: [] }),
17
+ getPromptSkills: () => [],
18
+ setSkillsIndexFilter: () => { },
17
19
  getPrompts: () => ({ prompts: [], diagnostics: [] }),
18
20
  getThemes: () => ({ themes: [], diagnostics: [] }),
19
21
  getAgents: () => ({ agents: [], diagnostics: [] }),
@@ -711,6 +711,7 @@ export class AgentSession {
711
711
  description: definition.description,
712
712
  parameters: definition.parameters,
713
713
  promptGuidelines: definition.promptGuidelines,
714
+ discovery: definition.discovery,
714
715
  sourceInfo,
715
716
  }));
716
717
  }
@@ -819,7 +820,7 @@ export class AgentSession {
819
820
  const loaderSystemPrompt = this._resourceLoader.getSystemPrompt();
820
821
  const loaderAppendSystemPrompt = this._resourceLoader.getAppendSystemPrompt();
821
822
  const appendSystemPrompt = loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : undefined;
822
- const loadedSkills = this._resourceLoader.getSkills().skills;
823
+ const loadedSkills = this._resourceLoader.getPromptSkills();
823
824
  const loadedAgents = this._resourceLoader.getAgents().agents;
824
825
  const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles;
825
826
  this._baseSystemPromptOptions = {
@@ -2249,6 +2250,22 @@ export class AgentSession {
2249
2250
  getActiveTools: () => this.getActiveToolNames(),
2250
2251
  getAllTools: () => this.getAllTools(),
2251
2252
  setActiveTools: (toolNames) => this.setActiveToolsByName(toolNames),
2253
+ getResolvedSkills: () => this._resourceLoader.getResolvedSkills().map((resource) => {
2254
+ const skill = this._resourceLoader.getSkills().skills.find((s) => s.filePath === resource.path);
2255
+ return {
2256
+ name: skill?.name ?? basename(dirname(resource.path)),
2257
+ description: skill?.description ?? "",
2258
+ category: skill?.category,
2259
+ filePath: resource.path,
2260
+ scope: resource.metadata.scope === "project" ? "project" : "user",
2261
+ disableModelInvocation: skill?.disableModelInvocation ?? false,
2262
+ };
2263
+ }),
2264
+ setSkillsIndexFilter: (filter) => {
2265
+ this._resourceLoader.setSkillsIndexFilter(filter);
2266
+ this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames());
2267
+ this.agent.state.systemPrompt = this._systemPromptOverride ?? this._baseSystemPrompt;
2268
+ },
2252
2269
  refreshTools: () => this._refreshToolRegistry(),
2253
2270
  getCommands,
2254
2271
  setModel: async (model) => {
@@ -6,6 +6,6 @@ export type { SourceInfo } from "../source-info.ts";
6
6
  export { createExtensionRuntime, discoverAndLoadExtensions, loadExtensionFromFactory, loadExtensions, } from "./loader.ts";
7
7
  export type { ExtensionErrorListener, ForkHandler, NavigateTreeHandler, NewSessionHandler, ShutdownHandler, SwitchSessionHandler, } from "./runner.ts";
8
8
  export { ExtensionRunner } from "./runner.ts";
9
- export type { AfterProviderResponseEvent, AgentEndEvent, AgentSettledEvent, AgentStartEvent, AgentToolResult, AgentToolUpdateCallback, AppendEntryHandler, AppKeybinding, AutocompleteProviderFactory, BashToolCallEvent, BashToolResultEvent, BeforeAgentStartEvent, BeforeAgentStartEventResult, BeforeProviderHeadersEvent, BeforeProviderRequestEvent, BeforeProviderRequestEventResult, BuildSystemPromptOptions, CompactOptions, ContextEvent, ContextEventResult, ContextUsage, CustomToolCallEvent, CustomToolResultEvent, EditorFactory, EditToolCallEvent, EditToolResultEvent, EntryRenderer, EntryRenderOptions, ExecOptions, ExecResult, Extension, ExtensionActions, ExtensionAPI, ExtensionCommandContext, ExtensionCommandContextActions, ExtensionContext, ExtensionContextActions, ExtensionError, ExtensionEvent, ExtensionFactory, ExtensionFlag, ExtensionHandler, ExtensionMode, ExtensionRuntime, ExtensionShortcut, ExtensionUIContext, ExtensionUIDialogOptions, ExtensionWidgetOptions, FindToolCallEvent, FindToolResultEvent, GetActiveToolsHandler, GetAllToolsHandler, GetCommandsHandler, GetThinkingLevelHandler, GrepToolCallEvent, GrepToolResultEvent, InlineExtension, InputEvent, InputEventResult, InputSource, KeybindingsManager, LoadExtensionsResult, LsToolCallEvent, LsToolResultEvent, MessageEndEvent, MessageRenderer, MessageRenderOptions, MessageStartEvent, MessageUpdateEvent, ModelSelectEvent, ModelSelectSource, ProjectTrustContext, ProjectTrustEvent, ProjectTrustEventDecision, ProjectTrustEventResult, ProjectTrustHandler, ProviderConfig, ProviderModelConfig, ReadToolCallEvent, ReadToolResultEvent, RegisteredCommand, RegisteredTool, ReplacedSessionContext, ResolvedCommand, ResourcesDiscoverEvent, ResourcesDiscoverResult, SendMessageHandler, SendUserMessageHandler, SessionBeforeCompactEvent, SessionBeforeCompactResult, SessionBeforeForkEvent, SessionBeforeForkResult, SessionBeforeSwitchEvent, SessionBeforeSwitchResult, SessionBeforeTreeEvent, SessionBeforeTreeResult, SessionCompactEvent, SessionEvent, SessionInfoChangedEvent, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent, SetActiveToolsHandler, SetLabelHandler, SetModelHandler, SetThinkingLevelHandler, TerminalInputHandler, ToolCallEvent, ToolCallEventResult, ToolDefinition, ToolExecutionEndEvent, ToolExecutionMode, ToolExecutionStartEvent, ToolExecutionUpdateEvent, ToolInfo, ToolRenderResultOptions, ToolResultEvent, ToolResultEventResult, TreePreparation, TurnEndEvent, TurnStartEvent, UIPromptEndEvent, UIPromptKind, UIPromptStartEvent, UserBashEvent, UserBashEventResult, WidgetPlacement, WorkingIndicatorOptions, WriteToolCallEvent, WriteToolResultEvent, } from "./types.ts";
9
+ export type { AfterProviderResponseEvent, AgentEndEvent, AgentSettledEvent, AgentStartEvent, AgentToolResult, AgentToolUpdateCallback, AppendEntryHandler, AppKeybinding, AutocompleteProviderFactory, BashToolCallEvent, BashToolResultEvent, BeforeAgentStartEvent, BeforeAgentStartEventResult, BeforeProviderHeadersEvent, BeforeProviderRequestEvent, BeforeProviderRequestEventResult, BuildSystemPromptOptions, CompactOptions, ContextEvent, ContextEventResult, ContextUsage, CustomToolCallEvent, CustomToolResultEvent, EditorFactory, EditToolCallEvent, EditToolResultEvent, EntryRenderer, EntryRenderOptions, ExecOptions, ExecResult, Extension, ExtensionActions, ExtensionAPI, ExtensionCommandContext, ExtensionCommandContextActions, ExtensionContext, ExtensionContextActions, ExtensionError, ExtensionEvent, ExtensionFactory, ExtensionFlag, ExtensionHandler, ExtensionMode, ExtensionRuntime, ExtensionShortcut, ExtensionUIContext, ExtensionUIDialogOptions, ExtensionWidgetOptions, FindToolCallEvent, FindToolResultEvent, GetActiveToolsHandler, GetAllToolsHandler, GetCommandsHandler, GetThinkingLevelHandler, GrepToolCallEvent, GrepToolResultEvent, InlineExtension, InputEvent, InputEventResult, InputSource, KeybindingsManager, LoadExtensionsResult, LsToolCallEvent, LsToolResultEvent, MessageEndEvent, MessageRenderer, MessageRenderOptions, MessageStartEvent, MessageUpdateEvent, ModelSelectEvent, ModelSelectSource, ProjectTrustContext, ProjectTrustEvent, ProjectTrustEventDecision, ProjectTrustEventResult, ProjectTrustHandler, ProviderConfig, ProviderModelConfig, ReadToolCallEvent, ReadToolResultEvent, RegisteredCommand, RegisteredTool, ReplacedSessionContext, ResolvedCommand, ResolvedSkillInfo, ResourcesDiscoverEvent, ResourcesDiscoverResult, SendMessageHandler, SendUserMessageHandler, SessionBeforeCompactEvent, SessionBeforeCompactResult, SessionBeforeForkEvent, SessionBeforeForkResult, SessionBeforeSwitchEvent, SessionBeforeSwitchResult, SessionBeforeTreeEvent, SessionBeforeTreeResult, SessionCompactEvent, SessionEvent, SessionInfoChangedEvent, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent, SetActiveToolsHandler, SetLabelHandler, SetModelHandler, SetThinkingLevelHandler, TerminalInputHandler, ToolCallEvent, ToolCallEventResult, ToolDefinition, ToolExecutionEndEvent, ToolExecutionMode, ToolExecutionStartEvent, ToolExecutionUpdateEvent, ToolInfo, ToolRenderResultOptions, ToolResultEvent, ToolResultEventResult, TreePreparation, TurnEndEvent, TurnStartEvent, UIPromptEndEvent, UIPromptKind, UIPromptStartEvent, UserBashEvent, UserBashEventResult, WidgetPlacement, WorkingIndicatorOptions, WriteToolCallEvent, WriteToolResultEvent, } from "./types.ts";
10
10
  export { defineTool, isBashToolResult, isEditToolResult, isFindToolResult, isGrepToolResult, isLsToolResult, isReadToolResult, isToolCallEventType, isWriteToolResult, } from "./types.ts";
11
11
  export { wrapRegisteredTool, wrapRegisteredTools } from "./wrapper.ts";
@@ -150,6 +150,8 @@ export function createExtensionRuntime() {
150
150
  getActiveTools: notInitialized,
151
151
  getAllTools: notInitialized,
152
152
  setActiveTools: notInitialized,
153
+ getResolvedSkills: notInitialized,
154
+ setSkillsIndexFilter: notInitialized,
153
155
  // registerTool() is valid during extension load; refresh is only needed post-bind.
154
156
  refreshTools: () => { },
155
157
  getCommands: notInitialized,
@@ -291,6 +293,14 @@ function createExtensionAPI(extension, runtime, cwd, eventBus) {
291
293
  assertActive();
292
294
  return runtime.getActiveTools();
293
295
  },
296
+ getResolvedSkills() {
297
+ assertActive();
298
+ return runtime.getResolvedSkills();
299
+ },
300
+ setSkillsIndexFilter(filter) {
301
+ assertActive();
302
+ runtime.setSkillsIndexFilter(filter);
303
+ },
294
304
  getAllTools() {
295
305
  assertActive();
296
306
  return runtime.getAllTools();
@@ -168,6 +168,8 @@ export class ExtensionRunner {
168
168
  this.runtime.getActiveTools = actions.getActiveTools;
169
169
  this.runtime.getAllTools = actions.getAllTools;
170
170
  this.runtime.setActiveTools = actions.setActiveTools;
171
+ this.runtime.getResolvedSkills = actions.getResolvedSkills;
172
+ this.runtime.setSkillsIndexFilter = actions.setSkillsIndexFilter;
171
173
  this.runtime.refreshTools = actions.refreshTools;
172
174
  this.runtime.getCommands = actions.getCommands;
173
175
  this.runtime.setModel = actions.setModel;
@@ -23,6 +23,7 @@ import type { ModelRegistry } from "../model-registry.ts";
23
23
  import type { ScopedModel } from "../model-resolver.ts";
24
24
  import type { BranchSummaryEntry, CompactionEntry, CustomEntry, ReadonlySessionManager, SessionEntry, SessionManager } from "../session-manager.ts";
25
25
  import type { SlashCommandInfo } from "../slash-commands.ts";
26
+ import type { Skill } from "../skills.ts";
26
27
  import type { SourceInfo } from "../source-info.ts";
27
28
  import type { BuildSystemPromptOptions } from "../system-prompt.ts";
28
29
  import type { BashOperations } from "../tools/bash.ts";
@@ -352,6 +353,20 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
352
353
  description: string;
353
354
  /** Optional one-line snippet for the Available tools section in the default system prompt. Custom tools are omitted from that section when this is not provided. */
354
355
  promptSnippet?: string;
356
+ /**
357
+ * Optional discovery metadata for the capability gateway. When present, the
358
+ * tool is eligible for catalog/discovery; when absent, a conservative
359
+ * name/first-sentence fallback summary is used. Never contains the full
360
+ * tool description or schema.
361
+ */
362
+ discovery?: {
363
+ /** Short one-line purpose shown in the catalog. */
364
+ summary?: string;
365
+ /** Alternative names the router can match. */
366
+ aliases?: string[];
367
+ /** Stable category label. */
368
+ category?: string;
369
+ };
355
370
  /** Optional guideline bullets appended to the default system prompt Guidelines section when this tool is active. */
356
371
  promptGuidelines?: string[];
357
372
  /** Parameter schema (TypeBox) */
@@ -972,6 +987,20 @@ export interface ExtensionAPI {
972
987
  exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
973
988
  /** Get the list of currently active tool names. */
974
989
  getActiveTools(): string[];
990
+ /**
991
+ * Get the resolved, trust-filtered skill catalog (enabled skills with
992
+ * metadata). Skills hidden from model invocation are flagged so callers
993
+ * can exclude them from automatic discovery.
994
+ */
995
+ getResolvedSkills(): ResolvedSkillInfo[];
996
+ /**
997
+ * Replace the skill view rendered into the system-prompt skill index.
998
+ * The filter receives the default eligible view (trust-filtered,
999
+ * model-invocation-eligible) and returns the view to render. Passing
1000
+ * undefined restores the default. The resolved skill catalog is never
1001
+ * mutated; explicit inline skill invocation is unaffected.
1002
+ */
1003
+ setSkillsIndexFilter(filter: ((skills: Skill[]) => Skill[]) | undefined): void;
975
1004
  /** Get all configured tools with parameter schema, prompt guidelines, and source metadata. */
976
1005
  getAllTools(): ToolInfo[];
977
1006
  /** Set the active tools by name. */
@@ -1161,9 +1190,18 @@ export type SetSessionNameHandler = (name: string) => void;
1161
1190
  export type GetSessionNameHandler = () => string | undefined;
1162
1191
  export type GetActiveToolsHandler = () => string[];
1163
1192
  /** Tool info with name, description, parameter schema, prompt guidelines, and source metadata. */
1164
- export type ToolInfo = Pick<ToolDefinition, "name" | "description" | "parameters" | "promptGuidelines"> & {
1193
+ export type ToolInfo = Pick<ToolDefinition, "name" | "description" | "parameters" | "promptGuidelines" | "discovery"> & {
1165
1194
  sourceInfo: SourceInfo;
1166
1195
  };
1196
+ /** A resolved, trust-filtered skill in the extension-facing catalog. */
1197
+ export interface ResolvedSkillInfo {
1198
+ name: string;
1199
+ description: string;
1200
+ category?: string;
1201
+ filePath: string;
1202
+ scope: "user" | "project" | "temporary";
1203
+ disableModelInvocation: boolean;
1204
+ }
1167
1205
  export type GetAllToolsHandler = () => ToolInfo[];
1168
1206
  export type GetCommandsHandler = () => SlashCommandInfo[];
1169
1207
  export type SetActiveToolsHandler = (toolNames: string[]) => void;
@@ -1217,6 +1255,8 @@ export interface ExtensionActions {
1217
1255
  getActiveTools: GetActiveToolsHandler;
1218
1256
  getAllTools: GetAllToolsHandler;
1219
1257
  setActiveTools: SetActiveToolsHandler;
1258
+ getResolvedSkills: () => ResolvedSkillInfo[];
1259
+ setSkillsIndexFilter: (filter: ((skills: Skill[]) => Skill[]) | undefined) => void;
1220
1260
  refreshTools: RefreshToolsHandler;
1221
1261
  getCommands: GetCommandsHandler;
1222
1262
  setModel: SetModelHandler;
@@ -35,6 +35,20 @@ export interface ResourceLoader {
35
35
  diagnostics: ResourceDiagnostic[];
36
36
  };
37
37
  getResolvedSkills(): ResolvedResource[];
38
+ /**
39
+ * Skills eligible for the system-prompt skill index (trust-filtered,
40
+ * model-invocation-eligible). Extensions may replace this view via
41
+ * setSkillsIndexFilter to implement progressive disclosure without
42
+ * touching the resolved catalog.
43
+ */
44
+ getPromptSkills(): Skill[];
45
+ /**
46
+ * Replace the skill view used when rendering the system-prompt skill index.
47
+ * The filter receives the default eligible view and returns the view to
48
+ * render. Passing undefined restores the default. The resolved catalog
49
+ * (getSkills/getResolvedSkills) is never mutated.
50
+ */
51
+ setSkillsIndexFilter(filter: ((skills: Skill[]) => Skill[]) | undefined): void;
38
52
  getPrompts(): {
39
53
  prompts: PromptTemplate[];
40
54
  diagnostics: ResourceDiagnostic[];
@@ -166,6 +180,7 @@ export declare class DefaultResourceLoader implements ResourceLoader {
166
180
  private skills;
167
181
  private skillDiagnostics;
168
182
  private resolvedSkills;
183
+ private skillsIndexFilter;
169
184
  private agents;
170
185
  private agentDiagnostics;
171
186
  private prompts;
@@ -193,6 +208,8 @@ export declare class DefaultResourceLoader implements ResourceLoader {
193
208
  diagnostics: ResourceDiagnostic[];
194
209
  };
195
210
  getResolvedSkills(): ResolvedResource[];
211
+ getPromptSkills(): Skill[];
212
+ setSkillsIndexFilter(filter: ((skills: Skill[]) => Skill[]) | undefined): void;
196
213
  getPrompts(): {
197
214
  prompts: PromptTemplate[];
198
215
  diagnostics: ResourceDiagnostic[];
@@ -141,6 +141,7 @@ export class DefaultResourceLoader {
141
141
  skills;
142
142
  skillDiagnostics;
143
143
  resolvedSkills = [];
144
+ skillsIndexFilter;
144
145
  agents;
145
146
  agentDiagnostics;
146
147
  prompts;
@@ -226,6 +227,13 @@ export class DefaultResourceLoader {
226
227
  getResolvedSkills() {
227
228
  return this.resolvedSkills;
228
229
  }
230
+ getPromptSkills() {
231
+ const eligible = this.skills.filter((skill) => !skill.disableModelInvocation);
232
+ return this.skillsIndexFilter ? this.skillsIndexFilter(eligible) : eligible;
233
+ }
234
+ setSkillsIndexFilter(filter) {
235
+ this.skillsIndexFilter = filter;
236
+ }
229
237
  getPrompts() {
230
238
  return { prompts: this.prompts, diagnostics: this.promptDiagnostics };
231
239
  }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Capability gateway catalog and deterministic router.
3
+ *
4
+ * Pure functions: no host imports, fully unit-testable. The router uses
5
+ * deterministic local scoring (name/alias matches above token overlap) and
6
+ * never calls a model.
7
+ */
8
+
9
+ import { readFileSync } from "node:fs";
10
+
11
+ import { parseFrontmatter, type ResolvedSkillInfo, type ToolInfo } from "@selesai/code";
12
+
13
+ export interface CatalogEntry {
14
+ name: string;
15
+ kind: "tool" | "skill";
16
+ summary: string;
17
+ aliases: string[];
18
+ category?: string;
19
+ eligible: boolean;
20
+ }
21
+
22
+ /** Built-in tools are never mediated by the gateway. */
23
+ export const BUILTIN_TOOL_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
24
+
25
+ /** First sentence of a description, used as the conservative fallback summary. */
26
+ export function firstSentence(text: string): string {
27
+ const trimmed = text.trim();
28
+ const end = trimmed.search(/[.!?](?:\s|$)/);
29
+ return (end === -1 ? trimmed : trimmed.slice(0, end + 1)).trim();
30
+ }
31
+
32
+ /** Compact summary for a tool: explicit discovery metadata wins, then first sentence, then snippet. */
33
+ export function toolSummary(tool: ToolInfo): string {
34
+ const explicit = tool.discovery?.summary?.trim();
35
+ if (explicit) return explicit;
36
+ const sentence = firstSentence(tool.description);
37
+ if (sentence) return sentence;
38
+ return tool.promptSnippet?.trim() || tool.name;
39
+ }
40
+
41
+ export function buildToolCatalog(tools: ToolInfo[], gatewayToolNames: Set<string>): CatalogEntry[] {
42
+ return tools
43
+ .filter((tool) => !BUILTIN_TOOL_NAMES.has(tool.name) && !gatewayToolNames.has(tool.name))
44
+ .map((tool) => ({
45
+ name: tool.name,
46
+ kind: "tool" as const,
47
+ summary: toolSummary(tool),
48
+ aliases: tool.discovery?.aliases ?? [],
49
+ category: tool.discovery?.category,
50
+ eligible: true,
51
+ }));
52
+ }
53
+
54
+ export function buildSkillCatalog(skills: ResolvedSkillInfo[]): CatalogEntry[] {
55
+ return skills
56
+ .filter((skill) => !skill.disableModelInvocation)
57
+ .map((skill) => ({
58
+ name: skill.name,
59
+ kind: "skill" as const,
60
+ summary: skill.description || skillSummaryFromFile(skill.filePath),
61
+ aliases: [],
62
+ category: skill.category,
63
+ eligible: true,
64
+ }));
65
+ }
66
+
67
+ // Disabled skills resolve as paths but never parse; read the frontmatter description instead.
68
+ function skillSummaryFromFile(filePath: string): string {
69
+ try {
70
+ const { frontmatter } = parseFrontmatter<{ description?: string }>(readFileSync(filePath, "utf-8"));
71
+ return firstSentence(frontmatter.description ?? "");
72
+ } catch {
73
+ return "";
74
+ }
75
+ }
76
+
77
+ export function normalizeQuery(text: string): string {
78
+ return text
79
+ .toLowerCase()
80
+ .replace(/[^a-z0-9\s-]/g, " ")
81
+ .replace(/\s+/g, " ")
82
+ .trim();
83
+ }
84
+
85
+ export function tokenize(text: string): string[] {
86
+ return normalizeQuery(text).split(/\s+/).filter(Boolean);
87
+ }
88
+
89
+ /** Words that carry no routing signal. */
90
+ const STOPWORDS = new Set([
91
+ "the", "a", "an", "to", "of", "for", "with", "and", "or", "use", "using", "me", "my", "i",
92
+ "please", "help", "can", "you", "do", "does", "is", "are", "on", "in", "at", "by", "from",
93
+ "this", "that", "it", "its", "want", "need", "run", "call", "invoke", "find", "show", "list",
94
+ "get", "load", "activate", "discover", "capability", "tool", "skill", "the", "a",
95
+ ]);
96
+
97
+ export interface RouteResult {
98
+ action: "activate" | "recommend" | "hint" | "none";
99
+ entry?: CatalogEntry;
100
+ candidates?: CatalogEntry[];
101
+ }
102
+
103
+ /**
104
+ * Deterministic routing over the compact catalog.
105
+ *
106
+ * - Exact name/alias match scores 3; prefix match scores 2; each content token
107
+ * shared with the name/alias or summary adds 1.
108
+ * - A unique tool with score >= 3 is auto-activated.
109
+ * - A unique skill with score >= 3 is recommended (never auto-loaded).
110
+ * - Score 2 or a tie at the top produces a catalog-discovery hint.
111
+ * - No signal produces no hint at all.
112
+ */
113
+ export function route(query: string, catalog: CatalogEntry[]): RouteResult {
114
+ const q = normalizeQuery(query);
115
+ if (!q) return { action: "none" };
116
+ const contentTokens = tokenize(q).filter((token) => !STOPWORDS.has(token));
117
+ if (contentTokens.length === 0) return { action: "none" };
118
+
119
+ const scored = catalog.map((entry) => {
120
+ const names = [entry.name, ...entry.aliases].map(normalizeQuery);
121
+ const nameTokens = new Set(names.flatMap((name) => name.split(/\s+/)));
122
+ const summaryTokens = new Set(tokenize(entry.summary));
123
+ let score = 0;
124
+ for (const name of names) {
125
+ if (name === q) score = Math.max(score, 3);
126
+ else if (name.startsWith(q) || q.startsWith(name)) score = Math.max(score, 2);
127
+ }
128
+ for (const token of contentTokens) {
129
+ if (nameTokens.has(token)) score += 3;
130
+ else if (token.length >= 3 && [...nameTokens].some((nameToken) => nameToken.startsWith(token))) score += 2;
131
+ if (summaryTokens.has(token)) score += 1;
132
+ }
133
+ return { entry, score };
134
+ });
135
+
136
+ const best = Math.max(0, ...scored.map((s) => s.score));
137
+ if (best === 0) return { action: "none" };
138
+ const top = scored.filter((s) => s.score === best);
139
+ if (top.length > 1) {
140
+ return { action: "hint", candidates: top.map((s) => s.entry) };
141
+ }
142
+ const winner = top[0]!.entry;
143
+ if (best >= 3) {
144
+ return { action: winner.kind === "tool" ? "activate" : "recommend", entry: winner };
145
+ }
146
+ if (best >= 2) {
147
+ return { action: "hint", candidates: [winner] };
148
+ }
149
+ return { action: "none" };
150
+ }
@@ -0,0 +1,128 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { describe, expect, it } from "vitest";
6
+ import type { ResolvedSkillInfo, ToolInfo } from "@selesai/code";
7
+ import {
8
+ buildSkillCatalog,
9
+ buildToolCatalog,
10
+ firstSentence,
11
+ route,
12
+ toolSummary,
13
+ type CatalogEntry,
14
+ } from "./catalog.ts";
15
+
16
+ const tool = (overrides: Partial<ToolInfo>): ToolInfo =>
17
+ ({
18
+ name: "grep_app_search",
19
+ description: "Search public GitHub code through grep.app. Returns one page.",
20
+ parameters: {} as never,
21
+ sourceInfo: { path: "/ext/index.ts", source: "local", scope: "user", origin: "top-level" },
22
+ ...overrides,
23
+ }) as ToolInfo;
24
+
25
+ const skill = (overrides: Partial<ResolvedSkillInfo>): ResolvedSkillInfo => ({
26
+ name: "research",
27
+ description: "Investigate a question against high-trust primary sources.",
28
+ filePath: "/skills/research/SKILL.md",
29
+ scope: "user",
30
+ disableModelInvocation: false,
31
+ ...overrides,
32
+ });
33
+
34
+ describe("catalog metadata", () => {
35
+ it("uses explicit discovery summary over the description first sentence", () => {
36
+ const t = tool({ discovery: { summary: "Search public GitHub code" } });
37
+ expect(toolSummary(t)).toBe("Search public GitHub code");
38
+ });
39
+
40
+ it("falls back to the first sentence of the description", () => {
41
+ expect(toolSummary(tool({}))).toBe("Search public GitHub code through grep.app.");
42
+ });
43
+
44
+ it("firstSentence handles short text without punctuation", () => {
45
+ expect(firstSentence("no punctuation here")).toBe("no punctuation here");
46
+ });
47
+
48
+ it("builds a tool catalog excluding built-ins and gateway tools", () => {
49
+ const tools = [
50
+ tool({ name: "read" }),
51
+ tool({ name: "capability_catalog" }),
52
+ tool({ name: "grep_app_search", discovery: { aliases: ["github-search"], category: "web" } }),
53
+ ];
54
+ const entries = buildToolCatalog(tools, new Set(["capability_catalog"]));
55
+ expect(entries.map((e) => e.name)).toEqual(["grep_app_search"]);
56
+ expect(entries[0]!.aliases).toEqual(["github-search"]);
57
+ expect(entries[0]!.category).toBe("web");
58
+ expect(entries[0]!.kind).toBe("tool");
59
+ });
60
+
61
+ it("builds a skill catalog excluding disable-model-invocation skills", () => {
62
+ const entries = buildSkillCatalog([
63
+ skill({ name: "research" }),
64
+ skill({ name: "secret", disableModelInvocation: true }),
65
+ ]);
66
+ expect(entries.map((e) => e.name)).toEqual(["research"]);
67
+ expect(entries[0]!.kind).toBe("skill");
68
+ });
69
+
70
+ it("falls back to the file frontmatter description when the resolved description is empty", () => {
71
+ const dir = mkdtempSync(join(tmpdir(), "gw-skill-"));
72
+ const filePath = join(dir, "SKILL.md");
73
+ writeFileSync(
74
+ filePath,
75
+ "---\nname: brandkit\ndescription: Premium brand-kit image generation skill for creating high-end brand-guidelines boards.\n---\nbody\n",
76
+ );
77
+ try {
78
+ const entries = buildSkillCatalog([skill({ name: "brandkit", description: "", filePath })]);
79
+ expect(entries[0]!.summary).toBe("Premium brand-kit image generation skill for creating high-end brand-guidelines boards.");
80
+ } finally {
81
+ rmSync(dir, { recursive: true, force: true });
82
+ }
83
+ });
84
+ });
85
+
86
+ describe("deterministic routing", () => {
87
+ const catalog: CatalogEntry[] = [
88
+ { name: "grep_app_search", kind: "tool", summary: "Search public GitHub code", aliases: ["github-search"], category: "web" },
89
+ { name: "grep_app_fetch", kind: "tool", summary: "Fetch a public GitHub file", aliases: [], category: "web" },
90
+ { name: "research", kind: "skill", summary: "Investigate a question against primary sources", aliases: [], category: "research" },
91
+ ];
92
+
93
+ it("auto-activates a unique high-confidence tool match", () => {
94
+ const result = route("search github code with grep_app_search", catalog);
95
+ expect(result.action).toBe("activate");
96
+ expect(result.entry?.name).toBe("grep_app_search");
97
+ });
98
+
99
+ it("matches by alias", () => {
100
+ const result = route("use github-search", catalog);
101
+ expect(result.action).toBe("activate");
102
+ expect(result.entry?.name).toBe("grep_app_search");
103
+ });
104
+
105
+ it("recommends a unique high-confidence skill without auto-loading", () => {
106
+ const result = route("do research on this topic", catalog);
107
+ expect(result.action).toBe("recommend");
108
+ expect(result.entry?.name).toBe("research");
109
+ });
110
+
111
+ it("returns a hint for ambiguous matches", () => {
112
+ const result = route("grep app", catalog);
113
+ expect(result.action).toBe("hint");
114
+ expect(result.candidates?.length).toBeGreaterThan(1);
115
+ });
116
+
117
+ it("returns none for unrelated prompts", () => {
118
+ expect(route("refactor the auth module", catalog).action).toBe("none");
119
+ expect(route("", catalog).action).toBe("none");
120
+ expect(route(" ", catalog).action).toBe("none");
121
+ });
122
+
123
+ it("is deterministic for the same input", () => {
124
+ const a = route("search github code", catalog);
125
+ const b = route("search github code", catalog);
126
+ expect(a).toEqual(b);
127
+ });
128
+ });