@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 +9 -0
- package/dist/core/agent-session-auto-handoff.test.js +2 -0
- package/dist/core/agent-session-length-continuation.test.js +2 -0
- package/dist/core/agent-session.js +18 -1
- package/dist/core/extensions/index.d.ts +1 -1
- package/dist/core/extensions/loader.js +10 -0
- package/dist/core/extensions/runner.js +2 -0
- package/dist/core/extensions/types.d.ts +41 -1
- package/dist/core/resource-loader.d.ts +17 -0
- package/dist/core/resource-loader.js +8 -0
- package/dist/extensions/capability-gateway/catalog.ts +150 -0
- package/dist/extensions/capability-gateway/index.test.ts +128 -0
- package/dist/extensions/capability-gateway/index.ts +318 -0
- package/dist/extensions/capability-gateway/integration.test.ts +215 -0
- package/dist/extensions/capability-gateway/package.json +15 -0
- package/dist/extensions/package.json +1 -0
- package/dist/extensions/pi-zentui/extensions/zentui/working-line.ts +9 -4
- package/dist/extensions/pi-zentui/test/working-line.test.ts +26 -0
- package/dist/index.d.ts +1 -1
- package/dist/skills/to-prd/SKILL.md +73 -0
- package/package.json +1 -1
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.
|
|
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
|
+
}
|
|
@@ -972,12 +972,17 @@ export function buildWorkingLinePreviewFrames(
|
|
|
972
972
|
}
|
|
973
973
|
|
|
974
974
|
function workingLineUi(ctx: WorkingLineContext): WorkingLineUi | undefined {
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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.
|
|
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": {
|