@selesai/code 0.13.14 → 0.13.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -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 +329 -0
- package/dist/extensions/capability-gateway/integration.test.ts +238 -0
- package/dist/extensions/capability-gateway/package.json +15 -0
- package/dist/extensions/package.json +1 -0
- package/dist/extensions/pi-web-agent/src/backends/config.ts +1 -0
- package/dist/extensions/pi-web-agent/src/backends/settings-reader.ts +5 -3
- package/dist/extensions/pi-web-agent/src/search/tokenin.ts +7 -3
- package/dist/extensions/pi-web-agent/src/types.ts +1 -1
- 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
|
@@ -0,0 +1,329 @@
|
|
|
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
|
+
const EMBEDDED_SKILL_BLOCK = /<skill\s+name="([^"]+)"[^>]*>[\s\S]*?<\/skill>/gi;
|
|
66
|
+
|
|
67
|
+
function routePrompt(prompt: string, entries: CatalogEntry[]): ReturnType<typeof route> {
|
|
68
|
+
const loadedSkills = new Set([...prompt.matchAll(EMBEDDED_SKILL_BLOCK)].map((match) => match[1]!.toLowerCase()));
|
|
69
|
+
const query = prompt.replace(EMBEDDED_SKILL_BLOCK, " ");
|
|
70
|
+
return route(
|
|
71
|
+
query,
|
|
72
|
+
entries.filter((entry) => entry.kind !== "skill" || !loadedSkills.has(entry.name.toLowerCase())),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatCatalog(entries: CatalogEntry[]): string {
|
|
77
|
+
const lines = entries.map(
|
|
78
|
+
(entry) =>
|
|
79
|
+
`- ${entry.kind} ${entry.name}${entry.category ? ` [${entry.category}]` : ""}: ${entry.summary}`,
|
|
80
|
+
);
|
|
81
|
+
return lines.length > 0 ? lines.join("\n") : "(no matching capabilities)";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function findEntry(entries: CatalogEntry[], name: string): CatalogEntry | undefined {
|
|
85
|
+
const normalized = name.trim().toLowerCase();
|
|
86
|
+
return entries.find(
|
|
87
|
+
(entry) => entry.name.toLowerCase() === normalized || entry.aliases.some((alias) => alias.toLowerCase() === normalized),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function skillByFile(pi: ExtensionAPI, filePath: string): { name: string; body: string } | undefined {
|
|
92
|
+
const skill = pi.getResolvedSkills().find((s) => s.filePath === filePath);
|
|
93
|
+
if (!skill) return undefined;
|
|
94
|
+
try {
|
|
95
|
+
const body = stripFrontmatter(readFileSync(skill.filePath, "utf-8")).trim();
|
|
96
|
+
return { name: skill.name, body };
|
|
97
|
+
} catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function emitTelemetry(pi: ExtensionAPI, event: string, data: Record<string, unknown>): void {
|
|
103
|
+
try {
|
|
104
|
+
pi.events.emit("capability-gateway", { event, ...data });
|
|
105
|
+
} catch {
|
|
106
|
+
// Telemetry must never break the session.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
|
|
111
|
+
if (!isEnabled()) return;
|
|
112
|
+
|
|
113
|
+
// ------------------------------------------------------------------
|
|
114
|
+
// Session start: snapshot baseline, make extension tools dormant, and
|
|
115
|
+
// install the compact skill index (action methods are stubs until bind).
|
|
116
|
+
// ------------------------------------------------------------------
|
|
117
|
+
pi.on("session_start", (_event, ctx) => {
|
|
118
|
+
const baseline = pi.getActiveTools();
|
|
119
|
+
const keep = baseline.filter(
|
|
120
|
+
(name) => !eligibleTools(pi).some((tool) => tool.name === name),
|
|
121
|
+
);
|
|
122
|
+
pi.setActiveTools(keep);
|
|
123
|
+
// Replace the eager skill index with a single compact capability
|
|
124
|
+
// instruction entry. Full skill instructions load only on show/invoke.
|
|
125
|
+
pi.setSkillsIndexFilter(() => [
|
|
126
|
+
{
|
|
127
|
+
name: "capability-gateway",
|
|
128
|
+
description: CAPABILITY_INSTRUCTION,
|
|
129
|
+
filePath: "<capability-gateway>",
|
|
130
|
+
baseDir: "<capability-gateway>",
|
|
131
|
+
sourceInfo: { path: "<capability-gateway>", source: "builtin", scope: "user", origin: "top-level" },
|
|
132
|
+
disableModelInvocation: false,
|
|
133
|
+
},
|
|
134
|
+
]);
|
|
135
|
+
emitTelemetry(pi, "session_start", { baselineCount: baseline.length, dormantCount: baseline.length - keep.length });
|
|
136
|
+
void ctx;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ------------------------------------------------------------------
|
|
140
|
+
// Tools
|
|
141
|
+
// ------------------------------------------------------------------
|
|
142
|
+
pi.registerTool({
|
|
143
|
+
name: "capability_catalog",
|
|
144
|
+
label: "Capability Catalog",
|
|
145
|
+
description:
|
|
146
|
+
"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.",
|
|
147
|
+
promptSnippet: "Search the compact catalog of optional tools and skills",
|
|
148
|
+
parameters: Type.Object({
|
|
149
|
+
query: Type.Optional(Type.String({ minLength: 1, description: "Natural-language query; omit to list all." })),
|
|
150
|
+
kind: Type.Optional(StringEnum(["tool", "skill"] as const, { description: "Filter by capability kind." })),
|
|
151
|
+
}),
|
|
152
|
+
async execute(_id, params) {
|
|
153
|
+
const entries = catalogEntries(pi);
|
|
154
|
+
const filtered = entries.filter(
|
|
155
|
+
(entry) => !params.kind || entry.kind === params.kind,
|
|
156
|
+
);
|
|
157
|
+
const matched = params.query ? route(params.query, filtered) : { action: "none" as const };
|
|
158
|
+
const shown =
|
|
159
|
+
params.query && matched.candidates
|
|
160
|
+
? matched.candidates
|
|
161
|
+
: params.query
|
|
162
|
+
? filtered.filter((entry) => {
|
|
163
|
+
const q = params.query!.toLowerCase();
|
|
164
|
+
return (
|
|
165
|
+
entry.name.toLowerCase().includes(q) ||
|
|
166
|
+
entry.summary.toLowerCase().includes(q) ||
|
|
167
|
+
entry.aliases.some((alias) => alias.toLowerCase().includes(q))
|
|
168
|
+
);
|
|
169
|
+
})
|
|
170
|
+
: filtered;
|
|
171
|
+
const text = formatCatalog(shown);
|
|
172
|
+
emitTelemetry(pi, "catalog", { query: params.query ?? "", kind: params.kind ?? "", results: shown.length });
|
|
173
|
+
return {
|
|
174
|
+
content: [{ type: "text", text }],
|
|
175
|
+
details: { count: shown.length, total: filtered.length },
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
pi.registerTool({
|
|
181
|
+
name: "capability_discover",
|
|
182
|
+
label: "Capability Discover",
|
|
183
|
+
description:
|
|
184
|
+
"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.",
|
|
185
|
+
promptSnippet: "Activate a catalogued extension tool for the current run",
|
|
186
|
+
parameters: Type.Object({
|
|
187
|
+
name: Type.String({ minLength: 1, description: "Exact catalogued tool name." }),
|
|
188
|
+
}),
|
|
189
|
+
async execute(_id, params) {
|
|
190
|
+
const entries = catalogEntries(pi);
|
|
191
|
+
const entry = findEntry(entries, params.name);
|
|
192
|
+
if (!entry || entry.kind !== "tool") {
|
|
193
|
+
return {
|
|
194
|
+
content: [
|
|
195
|
+
{
|
|
196
|
+
type: "text",
|
|
197
|
+
text: `Unknown tool "${params.name}". Search capability_catalog for the exact name, or refine your query.`,
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
details: { activated: false },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const active = pi.getActiveTools();
|
|
204
|
+
if (!active.includes(entry.name)) {
|
|
205
|
+
pi.setActiveTools([...active, entry.name]);
|
|
206
|
+
}
|
|
207
|
+
emitTelemetry(pi, "discover", { tool: entry.name });
|
|
208
|
+
return {
|
|
209
|
+
content: [
|
|
210
|
+
{
|
|
211
|
+
type: "text",
|
|
212
|
+
text: `Activated "${entry.name}" for this run. Call it normally on the next turn; it is removed when the run ends.`,
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
details: { activated: true, tool: entry.name },
|
|
216
|
+
};
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
pi.registerTool({
|
|
221
|
+
name: "capability_skill_show",
|
|
222
|
+
label: "Capability Skill Show",
|
|
223
|
+
description:
|
|
224
|
+
"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.",
|
|
225
|
+
promptSnippet: "Load a skill's full instructions by name",
|
|
226
|
+
parameters: Type.Object({
|
|
227
|
+
name: Type.String({ minLength: 1, description: "Exact skill name." }),
|
|
228
|
+
}),
|
|
229
|
+
async execute(_id, params) {
|
|
230
|
+
const skill = pi.getResolvedSkills().find((s) => s.name === params.name);
|
|
231
|
+
if (!skill) {
|
|
232
|
+
return {
|
|
233
|
+
content: [
|
|
234
|
+
{
|
|
235
|
+
type: "text",
|
|
236
|
+
text: `Unknown skill "${params.name}". Search capability_catalog (kind: "skill") for the exact name.`,
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
details: { loaded: false },
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const loaded = skillByFile(pi, skill.filePath);
|
|
243
|
+
if (!loaded) {
|
|
244
|
+
return {
|
|
245
|
+
content: [{ type: "text", text: `Skill "${params.name}" exists but its file could not be read.` }],
|
|
246
|
+
details: { loaded: false },
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
emitTelemetry(pi, "skill_show", { skill: loaded.name });
|
|
250
|
+
return {
|
|
251
|
+
content: [
|
|
252
|
+
{
|
|
253
|
+
type: "text",
|
|
254
|
+
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>`,
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
details: { loaded: true, skill: loaded.name },
|
|
258
|
+
};
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ------------------------------------------------------------------
|
|
263
|
+
// Deterministic routing: high-confidence activation/recommendation,
|
|
264
|
+
// ambiguity hints, or nothing for unrelated prompts.
|
|
265
|
+
// ------------------------------------------------------------------
|
|
266
|
+
pi.on("before_agent_start", (event) => {
|
|
267
|
+
const result = routePrompt(event.prompt, catalogEntries(pi));
|
|
268
|
+
if (result.action === "none") return undefined;
|
|
269
|
+
if (result.action === "activate" && result.entry) {
|
|
270
|
+
const active = pi.getActiveTools();
|
|
271
|
+
if (!active.includes(result.entry.name)) {
|
|
272
|
+
pi.setActiveTools([...active, result.entry.name]);
|
|
273
|
+
}
|
|
274
|
+
emitTelemetry(pi, "route_activate", { tool: result.entry.name });
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
if (result.action === "recommend" && result.entry) {
|
|
278
|
+
emitTelemetry(pi, "route_recommend", { skill: result.entry.name });
|
|
279
|
+
return {
|
|
280
|
+
message: {
|
|
281
|
+
customType: "capability-gateway-hint",
|
|
282
|
+
content: `The request matches the optional skill "${result.entry.name}". Load it with capability_skill_show before applying it.`,
|
|
283
|
+
display: false,
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (result.action === "hint" && result.candidates) {
|
|
288
|
+
const names = result.candidates.map((c) => c.name).join(", ");
|
|
289
|
+
emitTelemetry(pi, "route_hint", { candidates: result.candidates.map((c) => c.name) });
|
|
290
|
+
return {
|
|
291
|
+
message: {
|
|
292
|
+
customType: "capability-gateway-hint",
|
|
293
|
+
content: `The request may match optional capabilities: ${names}. Search capability_catalog to confirm before selecting.`,
|
|
294
|
+
display: false,
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
return undefined;
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// ------------------------------------------------------------------
|
|
302
|
+
// Reset: restore the baseline active-tool set after the run settles.
|
|
303
|
+
// ------------------------------------------------------------------
|
|
304
|
+
pi.on("agent_settled", () => {
|
|
305
|
+
const baseline = pi.getActiveTools().filter((name) => !eligibleTools(pi).some((tool) => tool.name === name));
|
|
306
|
+
pi.setActiveTools(baseline);
|
|
307
|
+
emitTelemetry(pi, "reset", { activeCount: baseline.length });
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ------------------------------------------------------------------
|
|
311
|
+
// Command: /capability-gateway status
|
|
312
|
+
// ------------------------------------------------------------------
|
|
313
|
+
pi.registerCommand("capability-gateway", {
|
|
314
|
+
description: "Show capability gateway status and catalog counts.",
|
|
315
|
+
async handler(_args, ctx) {
|
|
316
|
+
const tools = buildToolCatalog(eligibleTools(pi), GATEWAY_TOOLS);
|
|
317
|
+
const skills = buildSkillCatalog(pi.getResolvedSkills());
|
|
318
|
+
const active = pi.getActiveTools();
|
|
319
|
+
const dormant = tools.filter((t) => !active.includes(t.name)).length;
|
|
320
|
+
const text = [
|
|
321
|
+
`Capability gateway: enabled`,
|
|
322
|
+
`catalogued tools: ${tools.length} (${dormant} dormant)`,
|
|
323
|
+
`catalogued skills: ${skills.length}`,
|
|
324
|
+
`active tools: ${active.join(", ") || "(none)"}`,
|
|
325
|
+
].join("\n");
|
|
326
|
+
ctx.ui.notify(text);
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
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
|
+
const INLINE_SKILLS_FILE = fileURLToPath(new URL("../inline-skills.ts", import.meta.url));
|
|
20
|
+
|
|
21
|
+
interface Harness {
|
|
22
|
+
session: AgentSession;
|
|
23
|
+
dispose: () => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function createGatewaySession(options: {
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
withSkill?: boolean;
|
|
29
|
+
extensions?: string[];
|
|
30
|
+
}): Promise<Harness> {
|
|
31
|
+
const extensionPaths = options.extensions ?? [EXTENSIONS_DIR];
|
|
32
|
+
const cwd = mkdtempSync(join(tmpdir(), "gw-cwd-"));
|
|
33
|
+
const home = mkdtempSync(join(tmpdir(), "gw-home-"));
|
|
34
|
+
const previousCwd = process.cwd();
|
|
35
|
+
const previousHome = process.env.HOME;
|
|
36
|
+
const previousUserProfile = process.env.USERPROFILE;
|
|
37
|
+
const previousAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
|
|
38
|
+
const previousGateway = process.env.SELESAI_CAPABILITY_GATEWAY;
|
|
39
|
+
|
|
40
|
+
process.chdir(cwd);
|
|
41
|
+
process.env.HOME = home;
|
|
42
|
+
process.env.USERPROFILE = home;
|
|
43
|
+
process.env.SELESAI_CODING_AGENT_DIR = home;
|
|
44
|
+
if (options.enabled) delete process.env.SELESAI_CAPABILITY_GATEWAY;
|
|
45
|
+
else process.env.SELESAI_CAPABILITY_GATEWAY = "0";
|
|
46
|
+
|
|
47
|
+
if (options.withSkill) {
|
|
48
|
+
mkdirSync(join(home, "skills", "research"), { recursive: true });
|
|
49
|
+
writeFileSync(
|
|
50
|
+
join(home, "skills", "research", "SKILL.md"),
|
|
51
|
+
"---\nname: research\ndescription: Investigate a question against high-trust primary sources.\n---\nResearch instructions body.\n",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const faux = fauxProvider({ provider: "faux-gw", models: [{ id: "gw", contextWindow: 200_000 }] });
|
|
56
|
+
const modelRuntime = await ModelRuntime.create({
|
|
57
|
+
authPath: join(home, "auth.json"),
|
|
58
|
+
modelsPath: null,
|
|
59
|
+
allowModelNetwork: false,
|
|
60
|
+
});
|
|
61
|
+
modelRuntime.registerProvider(faux.provider.id, {
|
|
62
|
+
name: faux.provider.name,
|
|
63
|
+
api: faux.api,
|
|
64
|
+
apiKey: "faux",
|
|
65
|
+
streamSimple: faux.provider.streamSimple,
|
|
66
|
+
models: [...faux.models],
|
|
67
|
+
});
|
|
68
|
+
await modelRuntime.refresh({ allowNetwork: false });
|
|
69
|
+
const model = modelRuntime.getModel(faux.provider.id, "gw");
|
|
70
|
+
if (!model) throw new Error("faux model not registered");
|
|
71
|
+
|
|
72
|
+
const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } });
|
|
73
|
+
const loader = new DefaultResourceLoader({
|
|
74
|
+
cwd,
|
|
75
|
+
agentDir: home,
|
|
76
|
+
settingsManager,
|
|
77
|
+
additionalExtensionPaths: extensionPaths,
|
|
78
|
+
noPromptTemplates: true,
|
|
79
|
+
noThemes: true,
|
|
80
|
+
noContextFiles: true,
|
|
81
|
+
});
|
|
82
|
+
await loader.reload();
|
|
83
|
+
|
|
84
|
+
const created = await createAgentSession({
|
|
85
|
+
cwd,
|
|
86
|
+
agentDir: home,
|
|
87
|
+
model,
|
|
88
|
+
modelRuntime,
|
|
89
|
+
resourceLoader: loader,
|
|
90
|
+
sessionManager: SessionManager.create(cwd, join(home, "sessions")),
|
|
91
|
+
settingsManager,
|
|
92
|
+
});
|
|
93
|
+
const session = created.session;
|
|
94
|
+
await session.bindExtensions({});
|
|
95
|
+
|
|
96
|
+
const dispose = async () => {
|
|
97
|
+
try {
|
|
98
|
+
await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
|
|
99
|
+
} catch {}
|
|
100
|
+
try {
|
|
101
|
+
session.dispose();
|
|
102
|
+
} catch {}
|
|
103
|
+
process.chdir(previousCwd);
|
|
104
|
+
if (previousHome === undefined) delete process.env.HOME;
|
|
105
|
+
else process.env.HOME = previousHome;
|
|
106
|
+
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
|
107
|
+
else process.env.USERPROFILE = previousUserProfile;
|
|
108
|
+
if (previousAgentDir === undefined) delete process.env.SELESAI_CODING_AGENT_DIR;
|
|
109
|
+
else process.env.SELESAI_CODING_AGENT_DIR = previousAgentDir;
|
|
110
|
+
if (previousGateway === undefined) delete process.env.SELESAI_CAPABILITY_GATEWAY;
|
|
111
|
+
else process.env.SELESAI_CAPABILITY_GATEWAY = previousGateway;
|
|
112
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
113
|
+
rmSync(home, { recursive: true, force: true });
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return { session, dispose };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const harnesses: Harness[] = [];
|
|
120
|
+
afterEach(async () => {
|
|
121
|
+
for (const h of harnesses.splice(0)) await h.dispose();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("capability gateway integration", () => {
|
|
125
|
+
it("keeps extension tools dormant and built-ins active when enabled", async () => {
|
|
126
|
+
const h = await createGatewaySession({ enabled: true });
|
|
127
|
+
harnesses.push(h);
|
|
128
|
+
const active = h.session.getActiveToolNames();
|
|
129
|
+
expect(active).toContain("read");
|
|
130
|
+
expect(active).toContain("bash");
|
|
131
|
+
expect(active).not.toContain("grep_app_search");
|
|
132
|
+
expect(active).not.toContain("subagent");
|
|
133
|
+
// Gateway's own tools stay active so the agent can discover.
|
|
134
|
+
expect(active).toContain("capability_catalog");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("keeps all-visible behavior when disabled (compatibility mode)", async () => {
|
|
138
|
+
const h = await createGatewaySession({ enabled: false });
|
|
139
|
+
harnesses.push(h);
|
|
140
|
+
const active = h.session.getActiveToolNames();
|
|
141
|
+
expect(active).toContain("read");
|
|
142
|
+
expect(active).toContain("grep_app_search");
|
|
143
|
+
expect(active).toContain("subagent");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("replaces the eager skill index with the compact capability instruction", async () => {
|
|
147
|
+
const h = await createGatewaySession({ enabled: true, withSkill: true });
|
|
148
|
+
harnesses.push(h);
|
|
149
|
+
const prompt = h.session.systemPrompt;
|
|
150
|
+
expect(prompt).toContain("capability_catalog");
|
|
151
|
+
expect(prompt).toContain("Never invent optional tool names");
|
|
152
|
+
// The full skill list is gone from the default prompt: the research
|
|
153
|
+
// skill name and body are absent, only the compact instruction remains.
|
|
154
|
+
expect(prompt).not.toContain("Research instructions body.");
|
|
155
|
+
const block = prompt.slice(prompt.indexOf("<available_skills>"), prompt.indexOf("</available_skills>"));
|
|
156
|
+
expect(block).not.toContain("research");
|
|
157
|
+
expect(block).toContain("capability-gateway");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("activates a discovered tool for the run and resets after agent_settled", async () => {
|
|
161
|
+
const h = await createGatewaySession({ enabled: true });
|
|
162
|
+
harnesses.push(h);
|
|
163
|
+
const baseline = h.session.getActiveToolNames();
|
|
164
|
+
expect(baseline).not.toContain("grep_app_search");
|
|
165
|
+
|
|
166
|
+
// capability_discover activates the native tool.
|
|
167
|
+
const discover = h.session.getToolDefinition("capability_discover");
|
|
168
|
+
expect(discover).toBeDefined();
|
|
169
|
+
const result = await discover!.execute("call-1", { name: "grep_app_search" }, undefined, undefined, {} as never);
|
|
170
|
+
expect(result.content[0]!.type).toBe("text");
|
|
171
|
+
expect(String(result.content[0]!.text)).toContain("Activated");
|
|
172
|
+
expect(h.session.getActiveToolNames()).toContain("grep_app_search");
|
|
173
|
+
|
|
174
|
+
// agent_settled restores the baseline.
|
|
175
|
+
await h.session.extensionRunner.emit({ type: "agent_settled" });
|
|
176
|
+
expect(h.session.getActiveToolNames()).toEqual(baseline);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("routes a high-confidence prompt to automatic activation", async () => {
|
|
180
|
+
// Minimal extension set (gateway + grep-app) so no other extension's
|
|
181
|
+
// before_agent_start handler interferes with the routing result.
|
|
182
|
+
const h = await createGatewaySession({ enabled: true, extensions: [GATEWAY_DIR, GREP_APP_DIR] });
|
|
183
|
+
harnesses.push(h);
|
|
184
|
+
expect(h.session.getActiveToolNames()).not.toContain("grep_app_search");
|
|
185
|
+
|
|
186
|
+
const runner = h.session.extensionRunner;
|
|
187
|
+
const result = await runner.emitBeforeAgentStart(
|
|
188
|
+
"search github code with grep_app_search",
|
|
189
|
+
undefined,
|
|
190
|
+
h.session.systemPrompt,
|
|
191
|
+
{ cwd: process.cwd() } as never,
|
|
192
|
+
);
|
|
193
|
+
expect(result).toBeUndefined();
|
|
194
|
+
expect(h.session.getActiveToolNames()).toContain("grep_app_search");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("does not auto-load skills from fuzzy matching", async () => {
|
|
198
|
+
const h = await createGatewaySession({
|
|
199
|
+
enabled: true,
|
|
200
|
+
withSkill: true,
|
|
201
|
+
extensions: [GATEWAY_DIR, GREP_APP_DIR],
|
|
202
|
+
});
|
|
203
|
+
harnesses.push(h);
|
|
204
|
+
const runner = h.session.extensionRunner;
|
|
205
|
+
const result = await runner.emitBeforeAgentStart(
|
|
206
|
+
"do research on this topic",
|
|
207
|
+
undefined,
|
|
208
|
+
h.session.systemPrompt,
|
|
209
|
+
{ cwd: process.cwd() } as never,
|
|
210
|
+
);
|
|
211
|
+
// A recommendation message is injected, but the skill body is not loaded
|
|
212
|
+
// into the prompt and the skill is not auto-activated.
|
|
213
|
+
expect(result?.messages?.[0]?.content).toContain("research");
|
|
214
|
+
expect(h.session.systemPrompt).not.toContain("Research instructions body.");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("does not recommend capability_skill_show for an inline-loaded $skill", async () => {
|
|
218
|
+
const h = await createGatewaySession({
|
|
219
|
+
enabled: true,
|
|
220
|
+
withSkill: true,
|
|
221
|
+
extensions: [GATEWAY_DIR, GREP_APP_DIR, INLINE_SKILLS_FILE],
|
|
222
|
+
});
|
|
223
|
+
harnesses.push(h);
|
|
224
|
+
const runner = h.session.extensionRunner;
|
|
225
|
+
const input = await runner.emitInput("Use $research now", undefined, "interactive", undefined);
|
|
226
|
+
expect(input.action).toBe("transform");
|
|
227
|
+
if (input.action !== "transform") throw new Error("inline skill was not expanded");
|
|
228
|
+
expect(input.text).toContain("Research instructions body.");
|
|
229
|
+
|
|
230
|
+
const result = await runner.emitBeforeAgentStart(
|
|
231
|
+
input.text,
|
|
232
|
+
undefined,
|
|
233
|
+
h.session.systemPrompt,
|
|
234
|
+
{ cwd: process.cwd() } as never,
|
|
235
|
+
);
|
|
236
|
+
expect(result).toBeUndefined();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -19,9 +19,11 @@ export function hasActiveTokenInAccount(authPath: string = join(getAgentDir(), '
|
|
|
19
19
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
|
|
20
20
|
const auth = parsed as { accounts?: unknown; activeId?: unknown };
|
|
21
21
|
if (!Array.isArray(auth.accounts) || typeof auth.activeId !== 'string') return false;
|
|
22
|
-
return auth.accounts.some(
|
|
23
|
-
(a
|
|
24
|
-
|
|
22
|
+
return auth.accounts.some((a) => {
|
|
23
|
+
if (!a || typeof a !== 'object' || Array.isArray(a)) return false;
|
|
24
|
+
const account = a as { id?: unknown; apiKey?: unknown };
|
|
25
|
+
return account.id === auth.activeId && typeof account.apiKey === 'string' && account.apiKey.trim() !== '';
|
|
26
|
+
});
|
|
25
27
|
} catch {
|
|
26
28
|
return false;
|
|
27
29
|
}
|
|
@@ -27,7 +27,9 @@ export function readActiveTokenInAccount(
|
|
|
27
27
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
|
|
28
28
|
const auth = parsed as TokenInAuth;
|
|
29
29
|
if (!Array.isArray(auth.accounts) || typeof auth.activeId !== 'string') return undefined;
|
|
30
|
-
return auth.accounts.find(
|
|
30
|
+
return auth.accounts.find(
|
|
31
|
+
(a) => a && typeof a === 'object' && a.id === auth.activeId && typeof a.apiKey === 'string' && a.apiKey.trim() !== ''
|
|
32
|
+
);
|
|
31
33
|
} catch {
|
|
32
34
|
return undefined;
|
|
33
35
|
}
|
|
@@ -82,8 +84,10 @@ export function createTokenInSearchTool({
|
|
|
82
84
|
});
|
|
83
85
|
}
|
|
84
86
|
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
+
const rootUrl = (account.baseUrl ?? TOKENIN_DEFAULT_BASE_URL)
|
|
88
|
+
.replace(/\/+$/, '')
|
|
89
|
+
.replace(/\/v1$/, '');
|
|
90
|
+
const url = `${rootUrl}/v1/search/${TOKENIN_SEARCH_TOOL_NAME}`;
|
|
87
91
|
|
|
88
92
|
try {
|
|
89
93
|
const response = await fetchImpl(url, {
|
|
@@ -34,7 +34,7 @@ export type ToolError = {
|
|
|
34
34
|
export type SearchMetadata = {
|
|
35
35
|
backend: SearchProviderName;
|
|
36
36
|
cacheHit: boolean;
|
|
37
|
-
fallbackFrom?:
|
|
37
|
+
fallbackFrom?: SearchProviderName;
|
|
38
38
|
fallbackReason?: string;
|
|
39
39
|
fanout?: FanoutMetadata;
|
|
40
40
|
};
|
|
@@ -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 };
|