@selesai/code 0.13.14 → 0.13.15

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,15 @@
2
2
 
3
3
  All notable changes to `@selesai/code` will be documented in this file.
4
4
 
5
+ ## [0.13.15] - 2026-09-06
6
+
7
+ ### Added
8
+ - **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`.
9
+
10
+ ### Fixed
11
+ - **Capability catalog metadata.** Disabled skills now recover their frontmatter descriptions, and catalog results are no longer capped at 20 entries.
12
+ - **Zentui stale-session updates.** Timer-driven working-line updates now fail open when a captured extension context becomes stale after session replacement or reload.
13
+
5
14
  ## [0.13.13] - 2026-09-04
6
15
 
7
16
  ### 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
+ });
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Progressive capability gateway (experimental, opt-in).
3
+ *
4
+ * Issue #1: keep the full tool/skill ecosystem reachable without paying to
5
+ * expose every extension-tool schema and skill description on every request.
6
+ *
7
+ * Behavior:
8
+ * - On by default; disable via SELESAI_CAPABILITY_GATEWAY=0.
9
+ * - At session start, extension tools (except the gateway's own tools) become
10
+ * dormant: they stay registered but are removed from the active tool set.
11
+ * Built-in tools are never touched.
12
+ * - A compact catalog tool lists eligible tools/skills with one-line summaries.
13
+ * - capability_discover validates one catalogued tool and activates its native
14
+ * definition for the current agent run; capability_skill_show loads exactly
15
+ * the selected skill instructions.
16
+ * - A deterministic router inspects each user prompt and, for a unique
17
+ * high-confidence tool match, activates it before the run; for a unique
18
+ * high-confidence skill match it adds a concise recommendation; ambiguous
19
+ * matches add a catalog hint; unrelated prompts are untouched.
20
+ * - Temporary activations reset at agent_settled, restoring the baseline
21
+ * active-tool set.
22
+ * - The system-prompt skill index is replaced by a compact capability
23
+ * instruction; full skill instructions load only on explicit show/invoke.
24
+ * - Telemetry events are emitted on the shared event bus.
25
+ */
26
+
27
+ import { readFileSync } from "node:fs";
28
+ import { dirname } from "node:path";
29
+ import { stripFrontmatter, type ExtensionAPI, type ToolInfo } from "@selesai/code";
30
+ import { StringEnum } from "@earendil-works/pi-ai";
31
+ import { Type } from "typebox";
32
+ import {
33
+ buildSkillCatalog,
34
+ buildToolCatalog,
35
+ BUILTIN_TOOL_NAMES,
36
+ route,
37
+ type CatalogEntry,
38
+ } from "./catalog.ts";
39
+
40
+ export const GATEWAY_ENV = "SELESAI_CAPABILITY_GATEWAY";
41
+ export const GATEWAY_TOOLS = new Set(["capability_catalog", "capability_discover", "capability_skill_show"]);
42
+
43
+ export const CAPABILITY_INSTRUCTION = `Optional capabilities (extension tools and skills) are not listed here by default. To use one:
44
+ - Search the compact catalog with capability_catalog (kind: "tool" or "skill", natural-language query) when no active tool fits or a specialized integration/workflow is requested.
45
+ - Activate a catalogued tool with capability_discover, then call it normally on the next turn.
46
+ - Load a skill's full instructions with capability_skill_show before applying it.
47
+ Never invent optional tool names, actions, or fields; discover them first.`;
48
+
49
+ function isEnabled(): boolean {
50
+ return process.env[GATEWAY_ENV] !== "0";
51
+ }
52
+
53
+ function eligibleTools(pi: ExtensionAPI): ToolInfo[] {
54
+ return pi
55
+ .getAllTools()
56
+ .filter((tool) => !GATEWAY_TOOLS.has(tool.name) && !BUILTIN_TOOL_NAMES.has(tool.name));
57
+ }
58
+
59
+ function catalogEntries(pi: ExtensionAPI): CatalogEntry[] {
60
+ const tools = buildToolCatalog(eligibleTools(pi), GATEWAY_TOOLS);
61
+ const skills = buildSkillCatalog(pi.getResolvedSkills());
62
+ return [...tools, ...skills];
63
+ }
64
+
65
+ function formatCatalog(entries: CatalogEntry[]): string {
66
+ const lines = entries.map(
67
+ (entry) =>
68
+ `- ${entry.kind} ${entry.name}${entry.category ? ` [${entry.category}]` : ""}: ${entry.summary}`,
69
+ );
70
+ return lines.length > 0 ? lines.join("\n") : "(no matching capabilities)";
71
+ }
72
+
73
+ function findEntry(entries: CatalogEntry[], name: string): CatalogEntry | undefined {
74
+ const normalized = name.trim().toLowerCase();
75
+ return entries.find(
76
+ (entry) => entry.name.toLowerCase() === normalized || entry.aliases.some((alias) => alias.toLowerCase() === normalized),
77
+ );
78
+ }
79
+
80
+ function skillByFile(pi: ExtensionAPI, filePath: string): { name: string; body: string } | undefined {
81
+ const skill = pi.getResolvedSkills().find((s) => s.filePath === filePath);
82
+ if (!skill) return undefined;
83
+ try {
84
+ const body = stripFrontmatter(readFileSync(skill.filePath, "utf-8")).trim();
85
+ return { name: skill.name, body };
86
+ } catch {
87
+ return undefined;
88
+ }
89
+ }
90
+
91
+ function emitTelemetry(pi: ExtensionAPI, event: string, data: Record<string, unknown>): void {
92
+ try {
93
+ pi.events.emit("capability-gateway", { event, ...data });
94
+ } catch {
95
+ // Telemetry must never break the session.
96
+ }
97
+ }
98
+
99
+ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
100
+ if (!isEnabled()) return;
101
+
102
+ // ------------------------------------------------------------------
103
+ // Session start: snapshot baseline, make extension tools dormant, and
104
+ // install the compact skill index (action methods are stubs until bind).
105
+ // ------------------------------------------------------------------
106
+ pi.on("session_start", (_event, ctx) => {
107
+ const baseline = pi.getActiveTools();
108
+ const keep = baseline.filter(
109
+ (name) => !eligibleTools(pi).some((tool) => tool.name === name),
110
+ );
111
+ pi.setActiveTools(keep);
112
+ // Replace the eager skill index with a single compact capability
113
+ // instruction entry. Full skill instructions load only on show/invoke.
114
+ pi.setSkillsIndexFilter(() => [
115
+ {
116
+ name: "capability-gateway",
117
+ description: CAPABILITY_INSTRUCTION,
118
+ filePath: "<capability-gateway>",
119
+ baseDir: "<capability-gateway>",
120
+ sourceInfo: { path: "<capability-gateway>", source: "builtin", scope: "user", origin: "top-level" },
121
+ disableModelInvocation: false,
122
+ },
123
+ ]);
124
+ emitTelemetry(pi, "session_start", { baselineCount: baseline.length, dormantCount: baseline.length - keep.length });
125
+ void ctx;
126
+ });
127
+
128
+ // ------------------------------------------------------------------
129
+ // Tools
130
+ // ------------------------------------------------------------------
131
+ pi.registerTool({
132
+ name: "capability_catalog",
133
+ label: "Capability Catalog",
134
+ description:
135
+ "Search the compact capability catalog for optional extension tools and skills. Returns name, kind (tool or skill), category, and a one-line purpose for each match. Use when no active tool fits or a specialized integration/workflow is requested.",
136
+ promptSnippet: "Search the compact catalog of optional tools and skills",
137
+ parameters: Type.Object({
138
+ query: Type.Optional(Type.String({ minLength: 1, description: "Natural-language query; omit to list all." })),
139
+ kind: Type.Optional(StringEnum(["tool", "skill"] as const, { description: "Filter by capability kind." })),
140
+ }),
141
+ async execute(_id, params) {
142
+ const entries = catalogEntries(pi);
143
+ const filtered = entries.filter(
144
+ (entry) => !params.kind || entry.kind === params.kind,
145
+ );
146
+ const matched = params.query ? route(params.query, filtered) : { action: "none" as const };
147
+ const shown =
148
+ params.query && matched.candidates
149
+ ? matched.candidates
150
+ : params.query
151
+ ? filtered.filter((entry) => {
152
+ const q = params.query!.toLowerCase();
153
+ return (
154
+ entry.name.toLowerCase().includes(q) ||
155
+ entry.summary.toLowerCase().includes(q) ||
156
+ entry.aliases.some((alias) => alias.toLowerCase().includes(q))
157
+ );
158
+ })
159
+ : filtered;
160
+ const text = formatCatalog(shown);
161
+ emitTelemetry(pi, "catalog", { query: params.query ?? "", kind: params.kind ?? "", results: shown.length });
162
+ return {
163
+ content: [{ type: "text", text }],
164
+ details: { count: shown.length, total: filtered.length },
165
+ };
166
+ },
167
+ });
168
+
169
+ pi.registerTool({
170
+ name: "capability_discover",
171
+ label: "Capability Discover",
172
+ description:
173
+ "Activate one catalogued extension tool for the current agent run. The tool's real schema and validation contract become available on the next model turn; it is removed again when the run ends. Use the exact name from capability_catalog.",
174
+ promptSnippet: "Activate a catalogued extension tool for the current run",
175
+ parameters: Type.Object({
176
+ name: Type.String({ minLength: 1, description: "Exact catalogued tool name." }),
177
+ }),
178
+ async execute(_id, params) {
179
+ const entries = catalogEntries(pi);
180
+ const entry = findEntry(entries, params.name);
181
+ if (!entry || entry.kind !== "tool") {
182
+ return {
183
+ content: [
184
+ {
185
+ type: "text",
186
+ text: `Unknown tool "${params.name}". Search capability_catalog for the exact name, or refine your query.`,
187
+ },
188
+ ],
189
+ details: { activated: false },
190
+ };
191
+ }
192
+ const active = pi.getActiveTools();
193
+ if (!active.includes(entry.name)) {
194
+ pi.setActiveTools([...active, entry.name]);
195
+ }
196
+ emitTelemetry(pi, "discover", { tool: entry.name });
197
+ return {
198
+ content: [
199
+ {
200
+ type: "text",
201
+ text: `Activated "${entry.name}" for this run. Call it normally on the next turn; it is removed when the run ends.`,
202
+ },
203
+ ],
204
+ details: { activated: true, tool: entry.name },
205
+ };
206
+ },
207
+ });
208
+
209
+ pi.registerTool({
210
+ name: "capability_skill_show",
211
+ label: "Capability Skill Show",
212
+ description:
213
+ "Load the complete instructions for one skill from the resolved skill catalog. Use the exact skill name from capability_catalog. This is the explicit boundary that loads full SKILL.md content.",
214
+ promptSnippet: "Load a skill's full instructions by name",
215
+ parameters: Type.Object({
216
+ name: Type.String({ minLength: 1, description: "Exact skill name." }),
217
+ }),
218
+ async execute(_id, params) {
219
+ const skill = pi.getResolvedSkills().find((s) => s.name === params.name);
220
+ if (!skill) {
221
+ return {
222
+ content: [
223
+ {
224
+ type: "text",
225
+ text: `Unknown skill "${params.name}". Search capability_catalog (kind: "skill") for the exact name.`,
226
+ },
227
+ ],
228
+ details: { loaded: false },
229
+ };
230
+ }
231
+ const loaded = skillByFile(pi, skill.filePath);
232
+ if (!loaded) {
233
+ return {
234
+ content: [{ type: "text", text: `Skill "${params.name}" exists but its file could not be read.` }],
235
+ details: { loaded: false },
236
+ };
237
+ }
238
+ emitTelemetry(pi, "skill_show", { skill: loaded.name });
239
+ return {
240
+ content: [
241
+ {
242
+ type: "text",
243
+ text: `<skill name="${loaded.name}" location="${skill.filePath}">\nThe full instructions for this skill are embedded inline below; do not read its file again.\nReferences are relative to ${dirname(skill.filePath)}.\n\n${loaded.body}\n</skill>`,
244
+ },
245
+ ],
246
+ details: { loaded: true, skill: loaded.name },
247
+ };
248
+ },
249
+ });
250
+
251
+ // ------------------------------------------------------------------
252
+ // Deterministic routing: high-confidence activation/recommendation,
253
+ // ambiguity hints, or nothing for unrelated prompts.
254
+ // ------------------------------------------------------------------
255
+ pi.on("before_agent_start", (event) => {
256
+ const result = route(event.prompt, catalogEntries(pi));
257
+ if (result.action === "none") return undefined;
258
+ if (result.action === "activate" && result.entry) {
259
+ const active = pi.getActiveTools();
260
+ if (!active.includes(result.entry.name)) {
261
+ pi.setActiveTools([...active, result.entry.name]);
262
+ }
263
+ emitTelemetry(pi, "route_activate", { tool: result.entry.name });
264
+ return undefined;
265
+ }
266
+ if (result.action === "recommend" && result.entry) {
267
+ emitTelemetry(pi, "route_recommend", { skill: result.entry.name });
268
+ return {
269
+ message: {
270
+ customType: "capability-gateway-hint",
271
+ content: `The request matches the optional skill "${result.entry.name}". Load it with capability_skill_show before applying it.`,
272
+ display: false,
273
+ },
274
+ };
275
+ }
276
+ if (result.action === "hint" && result.candidates) {
277
+ const names = result.candidates.map((c) => c.name).join(", ");
278
+ emitTelemetry(pi, "route_hint", { candidates: result.candidates.map((c) => c.name) });
279
+ return {
280
+ message: {
281
+ customType: "capability-gateway-hint",
282
+ content: `The request may match optional capabilities: ${names}. Search capability_catalog to confirm before selecting.`,
283
+ display: false,
284
+ },
285
+ };
286
+ }
287
+ return undefined;
288
+ });
289
+
290
+ // ------------------------------------------------------------------
291
+ // Reset: restore the baseline active-tool set after the run settles.
292
+ // ------------------------------------------------------------------
293
+ pi.on("agent_settled", () => {
294
+ const baseline = pi.getActiveTools().filter((name) => !eligibleTools(pi).some((tool) => tool.name === name));
295
+ pi.setActiveTools(baseline);
296
+ emitTelemetry(pi, "reset", { activeCount: baseline.length });
297
+ });
298
+
299
+ // ------------------------------------------------------------------
300
+ // Command: /capability-gateway status
301
+ // ------------------------------------------------------------------
302
+ pi.registerCommand("capability-gateway", {
303
+ description: "Show capability gateway status and catalog counts.",
304
+ async handler(_args, ctx) {
305
+ const tools = buildToolCatalog(eligibleTools(pi), GATEWAY_TOOLS);
306
+ const skills = buildSkillCatalog(pi.getResolvedSkills());
307
+ const active = pi.getActiveTools();
308
+ const dormant = tools.filter((t) => !active.includes(t.name)).length;
309
+ const text = [
310
+ `Capability gateway: enabled`,
311
+ `catalogued tools: ${tools.length} (${dormant} dormant)`,
312
+ `catalogued skills: ${skills.length}`,
313
+ `active tools: ${active.join(", ") || "(none)"}`,
314
+ ].join("\n");
315
+ ctx.ui.notify(text);
316
+ },
317
+ });
318
+ }
@@ -0,0 +1,215 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { fauxProvider } from "@earendil-works/pi-ai/providers/faux";
7
+ import {
8
+ createAgentSession,
9
+ DefaultResourceLoader,
10
+ ModelRuntime,
11
+ SessionManager,
12
+ SettingsManager,
13
+ type AgentSession,
14
+ } from "@selesai/code";
15
+
16
+ const EXTENSIONS_DIR = fileURLToPath(new URL("../../", import.meta.url));
17
+ const GATEWAY_DIR = fileURLToPath(new URL(".", import.meta.url));
18
+ const GREP_APP_DIR = fileURLToPath(new URL("../grep-app", import.meta.url));
19
+
20
+ interface Harness {
21
+ session: AgentSession;
22
+ dispose: () => Promise<void>;
23
+ }
24
+
25
+ async function createGatewaySession(options: {
26
+ enabled: boolean;
27
+ withSkill?: boolean;
28
+ extensions?: string[];
29
+ }): Promise<Harness> {
30
+ const extensionPaths = options.extensions ?? [EXTENSIONS_DIR];
31
+ const cwd = mkdtempSync(join(tmpdir(), "gw-cwd-"));
32
+ const home = mkdtempSync(join(tmpdir(), "gw-home-"));
33
+ const previousCwd = process.cwd();
34
+ const previousHome = process.env.HOME;
35
+ const previousUserProfile = process.env.USERPROFILE;
36
+ const previousAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
37
+ const previousGateway = process.env.SELESAI_CAPABILITY_GATEWAY;
38
+
39
+ process.chdir(cwd);
40
+ process.env.HOME = home;
41
+ process.env.USERPROFILE = home;
42
+ process.env.SELESAI_CODING_AGENT_DIR = home;
43
+ if (options.enabled) delete process.env.SELESAI_CAPABILITY_GATEWAY;
44
+ else process.env.SELESAI_CAPABILITY_GATEWAY = "0";
45
+
46
+ if (options.withSkill) {
47
+ mkdirSync(join(home, "skills", "research"), { recursive: true });
48
+ writeFileSync(
49
+ join(home, "skills", "research", "SKILL.md"),
50
+ "---\nname: research\ndescription: Investigate a question against high-trust primary sources.\n---\nResearch instructions body.\n",
51
+ );
52
+ }
53
+
54
+ const faux = fauxProvider({ provider: "faux-gw", models: [{ id: "gw", contextWindow: 200_000 }] });
55
+ const modelRuntime = await ModelRuntime.create({
56
+ authPath: join(home, "auth.json"),
57
+ modelsPath: null,
58
+ allowModelNetwork: false,
59
+ });
60
+ modelRuntime.registerProvider(faux.provider.id, {
61
+ name: faux.provider.name,
62
+ api: faux.api,
63
+ apiKey: "faux",
64
+ streamSimple: faux.provider.streamSimple,
65
+ models: [...faux.models],
66
+ });
67
+ await modelRuntime.refresh({ allowNetwork: false });
68
+ const model = modelRuntime.getModel(faux.provider.id, "gw");
69
+ if (!model) throw new Error("faux model not registered");
70
+
71
+ const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } });
72
+ const loader = new DefaultResourceLoader({
73
+ cwd,
74
+ agentDir: home,
75
+ settingsManager,
76
+ additionalExtensionPaths: extensionPaths,
77
+ noPromptTemplates: true,
78
+ noThemes: true,
79
+ noContextFiles: true,
80
+ });
81
+ await loader.reload();
82
+
83
+ const created = await createAgentSession({
84
+ cwd,
85
+ agentDir: home,
86
+ model,
87
+ modelRuntime,
88
+ resourceLoader: loader,
89
+ sessionManager: SessionManager.create(cwd, join(home, "sessions")),
90
+ settingsManager,
91
+ });
92
+ const session = created.session;
93
+ await session.bindExtensions({});
94
+
95
+ const dispose = async () => {
96
+ try {
97
+ await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
98
+ } catch {}
99
+ try {
100
+ session.dispose();
101
+ } catch {}
102
+ process.chdir(previousCwd);
103
+ if (previousHome === undefined) delete process.env.HOME;
104
+ else process.env.HOME = previousHome;
105
+ if (previousUserProfile === undefined) delete process.env.USERPROFILE;
106
+ else process.env.USERPROFILE = previousUserProfile;
107
+ if (previousAgentDir === undefined) delete process.env.SELESAI_CODING_AGENT_DIR;
108
+ else process.env.SELESAI_CODING_AGENT_DIR = previousAgentDir;
109
+ if (previousGateway === undefined) delete process.env.SELESAI_CAPABILITY_GATEWAY;
110
+ else process.env.SELESAI_CAPABILITY_GATEWAY = previousGateway;
111
+ rmSync(cwd, { recursive: true, force: true });
112
+ rmSync(home, { recursive: true, force: true });
113
+ };
114
+
115
+ return { session, dispose };
116
+ }
117
+
118
+ const harnesses: Harness[] = [];
119
+ afterEach(async () => {
120
+ for (const h of harnesses.splice(0)) await h.dispose();
121
+ });
122
+
123
+ describe("capability gateway integration", () => {
124
+ it("keeps extension tools dormant and built-ins active when enabled", async () => {
125
+ const h = await createGatewaySession({ enabled: true });
126
+ harnesses.push(h);
127
+ const active = h.session.getActiveToolNames();
128
+ expect(active).toContain("read");
129
+ expect(active).toContain("bash");
130
+ expect(active).not.toContain("grep_app_search");
131
+ expect(active).not.toContain("subagent");
132
+ // Gateway's own tools stay active so the agent can discover.
133
+ expect(active).toContain("capability_catalog");
134
+ });
135
+
136
+ it("keeps all-visible behavior when disabled (compatibility mode)", async () => {
137
+ const h = await createGatewaySession({ enabled: false });
138
+ harnesses.push(h);
139
+ const active = h.session.getActiveToolNames();
140
+ expect(active).toContain("read");
141
+ expect(active).toContain("grep_app_search");
142
+ expect(active).toContain("subagent");
143
+ });
144
+
145
+ it("replaces the eager skill index with the compact capability instruction", async () => {
146
+ const h = await createGatewaySession({ enabled: true, withSkill: true });
147
+ harnesses.push(h);
148
+ const prompt = h.session.systemPrompt;
149
+ expect(prompt).toContain("capability_catalog");
150
+ expect(prompt).toContain("Never invent optional tool names");
151
+ // The full skill list is gone from the default prompt: the research
152
+ // skill name and body are absent, only the compact instruction remains.
153
+ expect(prompt).not.toContain("Research instructions body.");
154
+ const block = prompt.slice(prompt.indexOf("<available_skills>"), prompt.indexOf("</available_skills>"));
155
+ expect(block).not.toContain("research");
156
+ expect(block).toContain("capability-gateway");
157
+ });
158
+
159
+ it("activates a discovered tool for the run and resets after agent_settled", async () => {
160
+ const h = await createGatewaySession({ enabled: true });
161
+ harnesses.push(h);
162
+ const baseline = h.session.getActiveToolNames();
163
+ expect(baseline).not.toContain("grep_app_search");
164
+
165
+ // capability_discover activates the native tool.
166
+ const discover = h.session.getToolDefinition("capability_discover");
167
+ expect(discover).toBeDefined();
168
+ const result = await discover!.execute("call-1", { name: "grep_app_search" }, undefined, undefined, {} as never);
169
+ expect(result.content[0]!.type).toBe("text");
170
+ expect(String(result.content[0]!.text)).toContain("Activated");
171
+ expect(h.session.getActiveToolNames()).toContain("grep_app_search");
172
+
173
+ // agent_settled restores the baseline.
174
+ await h.session.extensionRunner.emit({ type: "agent_settled" });
175
+ expect(h.session.getActiveToolNames()).toEqual(baseline);
176
+ });
177
+
178
+ it("routes a high-confidence prompt to automatic activation", async () => {
179
+ // Minimal extension set (gateway + grep-app) so no other extension's
180
+ // before_agent_start handler interferes with the routing result.
181
+ const h = await createGatewaySession({ enabled: true, extensions: [GATEWAY_DIR, GREP_APP_DIR] });
182
+ harnesses.push(h);
183
+ expect(h.session.getActiveToolNames()).not.toContain("grep_app_search");
184
+
185
+ const runner = h.session.extensionRunner;
186
+ const result = await runner.emitBeforeAgentStart(
187
+ "search github code with grep_app_search",
188
+ undefined,
189
+ h.session.systemPrompt,
190
+ { cwd: process.cwd() } as never,
191
+ );
192
+ expect(result).toBeUndefined();
193
+ expect(h.session.getActiveToolNames()).toContain("grep_app_search");
194
+ });
195
+
196
+ it("does not auto-load skills from fuzzy matching", async () => {
197
+ const h = await createGatewaySession({
198
+ enabled: true,
199
+ withSkill: true,
200
+ extensions: [GATEWAY_DIR, GREP_APP_DIR],
201
+ });
202
+ harnesses.push(h);
203
+ const runner = h.session.extensionRunner;
204
+ const result = await runner.emitBeforeAgentStart(
205
+ "do research on this topic",
206
+ undefined,
207
+ h.session.systemPrompt,
208
+ { cwd: process.cwd() } as never,
209
+ );
210
+ // A recommendation message is injected, but the skill body is not loaded
211
+ // into the prompt and the skill is not auto-activated.
212
+ expect(result?.messages?.[0]?.content).toContain("research");
213
+ expect(h.session.systemPrompt).not.toContain("Research instructions body.");
214
+ });
215
+ });
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@selesai/capability-gateway",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Experimental progressive capability gateway: dormant extension tools and skills with compact catalog, deterministic routing, and native activation.",
6
+ "type": "module",
7
+ "pi": {
8
+ "extensions": ["./index.ts"]
9
+ },
10
+ "peerDependencies": {
11
+ "@selesai/code": "*",
12
+ "@earendil-works/pi-ai": "*",
13
+ "typebox": "*"
14
+ }
15
+ }
@@ -26,6 +26,7 @@
26
26
  "./pi-hermes-memory",
27
27
  "./web-agent-onboarding.ts",
28
28
  "./tps.ts",
29
+ "./capability-gateway",
29
30
  "./pi-zentui/extensions/zentui/index.ts"
30
31
  ]
31
32
  }
@@ -972,12 +972,17 @@ export function buildWorkingLinePreviewFrames(
972
972
  }
973
973
 
974
974
  function workingLineUi(ctx: WorkingLineContext): WorkingLineUi | undefined {
975
- if (ctx.hasUI === false || (ctx.mode !== undefined && ctx.mode !== "tui")) return undefined;
976
- const ui = ctx.ui as unknown as Partial<WorkingLineUi>;
977
- if (typeof ui.setWorkingMessage !== "function" || typeof ui.setWorkingIndicator !== "function") {
975
+ try {
976
+ if (ctx.hasUI === false || (ctx.mode !== undefined && ctx.mode !== "tui")) return undefined;
977
+ const ui = ctx.ui as unknown as Partial<WorkingLineUi>;
978
+ if (typeof ui.setWorkingMessage !== "function" || typeof ui.setWorkingIndicator !== "function") {
979
+ return undefined;
980
+ }
981
+ return ui as WorkingLineUi;
982
+ } catch {
983
+ // A stale ctx (after session replacement or reload) must not crash timer-driven updates.
978
984
  return undefined;
979
985
  }
980
- return ui as WorkingLineUi;
981
986
  }
982
987
 
983
988
  export type WorkingLineReconcileResult = { applied: boolean; reason?: string };
@@ -1684,6 +1684,32 @@ describe("working-line runtime ownership", () => {
1684
1684
  expect(phaseSignature(retried.frames[0] ?? "")[0]).toBe("⣏⠀⣹");
1685
1685
  });
1686
1686
 
1687
+ it("drops timer-driven updates when the captured ctx goes stale after session replacement", () => {
1688
+ vi.useFakeTimers();
1689
+ try {
1690
+ const harness = runtime(true);
1691
+ harness.controller.startSession(harness.ctx);
1692
+ harness.clock.start();
1693
+ harness.controller.startAgent(harness.ctx);
1694
+ harness.controller.startTurn(harness.ctx);
1695
+ harness.calls.length = 0;
1696
+ // Simulate a session replacement invalidating the captured ctx: its getters throw.
1697
+ const stale = harness.ctx as { hasUI: boolean };
1698
+ Object.defineProperty(stale, "hasUI", {
1699
+ get() {
1700
+ throw new Error("stale ctx");
1701
+ },
1702
+ configurable: true,
1703
+ });
1704
+ expect(() => vi.advanceTimersByTime(1000)).not.toThrow();
1705
+ expect(harness.calls).toEqual([]);
1706
+ harness.controller.dispose(harness.ctx);
1707
+ harness.clock.reset();
1708
+ } finally {
1709
+ vi.useRealTimers();
1710
+ }
1711
+ });
1712
+
1687
1713
  it("releases ownership when recovery also throws and waits for a clean reinstall", () => {
1688
1714
  const current = config();
1689
1715
  current.components.workingLine.enabled = true;
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export { readStoredCredential } from "./core/auth-storage.ts";
5
5
  export { ensureTool, getToolPath, type ManagedTool } from "./utils/tools-manager.ts";
6
6
  export { type BranchPreparation, type BranchSummaryResult, type CollectEntriesResult, type CompactionResult, type CutPointResult, calculateContextTokens, collectEntriesForBranchSummary, compact, DEFAULT_COMPACTION_SETTINGS, estimateTokens, type FileOperations, findCutPoint, findTurnStartIndex, type GenerateBranchSummaryOptions, generateBranchSummary, generateSummary, generateSummaryWithUsage, getLastAssistantUsage, prepareBranchEntries, serializeConversation, shouldCompact, } from "./core/compaction/index.ts";
7
7
  export { createEventBus, type EventBus, type EventBusController } from "./core/event-bus.ts";
8
- export type { AgentEndEvent, AgentSettledEvent, AgentStartEvent, AgentToolResult, AgentToolUpdateCallback, AppKeybinding, AutocompleteProviderFactory, BashToolCallEvent, BeforeAgentStartEvent, BeforeAgentStartEventResult, BeforeProviderHeadersEvent, BeforeProviderRequestEvent, BeforeProviderRequestEventResult, BuildSystemPromptOptions, CompactOptions, ContextEvent, ContextUsage, CustomToolCallEvent, EditToolCallEvent, EntryRenderer, EntryRenderOptions, ExecOptions, ExecResult, Extension, ExtensionActions, ExtensionAPI, ExtensionCommandContext, ExtensionCommandContextActions, ExtensionContext, ExtensionContextActions, ExtensionError, ExtensionEvent, ExtensionFactory, ExtensionFlag, ExtensionHandler, ExtensionRuntime, ExtensionShortcut, ExtensionUIContext, ExtensionUIDialogOptions, ExtensionWidgetOptions, FindToolCallEvent, GrepToolCallEvent, InlineExtension, InputEvent, InputEventResult, InputSource, KeybindingsManager, LoadExtensionsResult, LsToolCallEvent, MessageEndEvent, MessageRenderer, MessageRenderOptions, MessageStartEvent, MessageUpdateEvent, ProjectTrustContext, ProjectTrustEvent, ProjectTrustEventDecision, ProjectTrustEventResult, ProjectTrustHandler, ProviderConfig, ProviderModelConfig, ReadToolCallEvent, RegisteredCommand, RegisteredTool, ResolvedCommand, SessionBeforeCompactEvent, SessionBeforeForkEvent, SessionBeforeSwitchEvent, SessionBeforeTreeEvent, SessionCompactEvent, SessionInfoChangedEvent, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent, SlashCommandInfo, SlashCommandSource, SourceInfo, TerminalInputHandler, ToolCallEvent, ToolCallEventResult, ToolDefinition, ToolExecutionEndEvent, ToolExecutionMode, ToolExecutionStartEvent, ToolExecutionUpdateEvent, ToolInfo, ToolRenderResultOptions, ToolResultEvent, TurnEndEvent, TurnStartEvent, UIPromptEndEvent, UIPromptKind, UIPromptStartEvent, UserBashEvent, UserBashEventResult, WidgetPlacement, WorkingIndicatorOptions, WriteToolCallEvent, } from "./core/extensions/index.ts";
8
+ export type { AgentEndEvent, AgentSettledEvent, AgentStartEvent, AgentToolResult, AgentToolUpdateCallback, AppKeybinding, AutocompleteProviderFactory, BashToolCallEvent, BeforeAgentStartEvent, BeforeAgentStartEventResult, BeforeProviderHeadersEvent, BeforeProviderRequestEvent, BeforeProviderRequestEventResult, BuildSystemPromptOptions, CompactOptions, ContextEvent, ContextUsage, CustomToolCallEvent, EditToolCallEvent, EntryRenderer, EntryRenderOptions, ExecOptions, ExecResult, Extension, ExtensionActions, ExtensionAPI, ExtensionCommandContext, ExtensionCommandContextActions, ExtensionContext, ExtensionContextActions, ExtensionError, ExtensionEvent, ExtensionFactory, ExtensionFlag, ExtensionHandler, ExtensionRuntime, ExtensionShortcut, ExtensionUIContext, ExtensionUIDialogOptions, ExtensionWidgetOptions, FindToolCallEvent, GrepToolCallEvent, InlineExtension, InputEvent, InputEventResult, InputSource, KeybindingsManager, LoadExtensionsResult, LsToolCallEvent, MessageEndEvent, MessageRenderer, MessageRenderOptions, MessageStartEvent, MessageUpdateEvent, ProjectTrustContext, ProjectTrustEvent, ProjectTrustEventDecision, ProjectTrustEventResult, ProjectTrustHandler, ProviderConfig, ProviderModelConfig, ReadToolCallEvent, RegisteredCommand, RegisteredTool, ResolvedCommand, ResolvedSkillInfo, SessionBeforeCompactEvent, SessionBeforeForkEvent, SessionBeforeSwitchEvent, SessionBeforeTreeEvent, SessionCompactEvent, SessionInfoChangedEvent, SessionShutdownEvent, SessionStartEvent, SessionTreeEvent, SlashCommandInfo, SlashCommandSource, SourceInfo, TerminalInputHandler, ToolCallEvent, ToolCallEventResult, ToolDefinition, ToolExecutionEndEvent, ToolExecutionMode, ToolExecutionStartEvent, ToolExecutionUpdateEvent, ToolInfo, ToolRenderResultOptions, ToolResultEvent, TurnEndEvent, TurnStartEvent, UIPromptEndEvent, UIPromptKind, UIPromptStartEvent, UserBashEvent, UserBashEventResult, WidgetPlacement, WorkingIndicatorOptions, WriteToolCallEvent, } from "./core/extensions/index.ts";
9
9
  export { createExtensionRuntime, defineTool, discoverAndLoadExtensions, ExtensionRunner, isBashToolResult, isEditToolResult, isFindToolResult, isGrepToolResult, isLsToolResult, isReadToolResult, isToolCallEventType, isWriteToolResult, wrapRegisteredTool, wrapRegisteredTools, } from "./core/extensions/index.ts";
10
10
  export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.ts";
11
11
  export { convertToLlm } from "./core/messages.ts";
@@ -0,0 +1,73 @@
1
+ ---
2
+ name: to-prd
3
+ description: Turn the current conversation context into a PRD and save it under .selesai/docs/prds in the current project. Use when user wants to create a PRD from the current context.
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ This skill takes the current conversation context and codebase understanding and produces a PRD. Do NOT interview the user — just synthesize what you already know.
8
+
9
+ ## Process
10
+
11
+ 1. Explore the repo to understand the current state of the codebase, if you haven't already. Use the project's domain glossary vocabulary throughout the PRD, and respect any ADRs in the area you're touching.
12
+
13
+ 2. Sketch out the seams at which you're going to test the feature. Existing seams should be preferred to new ones. Use the highest seam possible. If new seams are needed, propose them at the highest point you can.
14
+
15
+ Check with the user that these seams match their expectations.
16
+
17
+ 3. Write the PRD using the template below, then save it as Markdown under `.selesai/docs/prds` relative to the current working directory. Create the directory if needed. Use a descriptive kebab-case filename ending in `.md`, and report the saved path to the user.
18
+
19
+ <prd-template>
20
+
21
+ ## Problem Statement
22
+
23
+ The problem that the user is facing, from the user's perspective.
24
+
25
+ ## Solution
26
+
27
+ The solution to the problem, from the user's perspective.
28
+
29
+ ## User Stories
30
+
31
+ A LONG, numbered list of user stories. Each user story should be in the format of:
32
+
33
+ 1. As an <actor>, I want a <feature>, so that <benefit>
34
+
35
+ <user-story-example>
36
+ 1. As a mobile bank customer, I want to see balance on my accounts, so that I can make better informed decisions about my spending
37
+ </user-story-example>
38
+
39
+ This list of user stories should be extremely extensive and cover all aspects of the feature.
40
+
41
+ ## Implementation Decisions
42
+
43
+ A list of implementation decisions that were made. This can include:
44
+
45
+ - The modules that will be built/modified
46
+ - The interfaces of those modules that will be modified
47
+ - Technical clarifications from the developer
48
+ - Architectural decisions
49
+ - Schema changes
50
+ - API contracts
51
+ - Specific interactions
52
+
53
+ Do NOT include specific file paths or code snippets. They may end up being outdated very quickly.
54
+
55
+ Exception: if a prototype produced a snippet that encodes a decision more precisely than prose can (state machine, reducer, schema, type shape), inline it within the relevant decision and note briefly that it came from a prototype. Trim to the decision-rich parts — not a working demo, just the important bits.
56
+
57
+ ## Testing Decisions
58
+
59
+ A list of testing decisions that were made. Include:
60
+
61
+ - A description of what makes a good test (only test external behavior, not implementation details)
62
+ - Which modules will be tested
63
+ - Prior art for the tests (i.e. similar types of tests in the codebase)
64
+
65
+ ## Out of Scope
66
+
67
+ A description of the things that are out of scope for this PRD.
68
+
69
+ ## Further Notes
70
+
71
+ Any further notes about the feature.
72
+
73
+ </prd-template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selesai/code",
3
- "version": "0.13.14",
3
+ "version": "0.13.15",
4
4
  "description": "Maintained, extension-first Pi coding agent with built-in workflows, subagents, web research, questions, skills, and an enhanced terminal UI.",
5
5
  "type": "module",
6
6
  "repository": {