@selesai/code 0.13.33 → 0.13.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to `@selesai/code` will be documented in this file.
4
4
 
5
+ ## [0.13.34] - 2026-09-29
6
+
7
+ ### Added
8
+ - **`ask_jev` lets the agent ask Jev typed questions.** One bounded call over the agent's own prose, up to eight file paths, and one command returns choice, score, or noul answers with confidences; file contents and command output are sent to Jev but never returned to the agent. On by default through `jevAdvisory.routes.ask`; without Token-In credentials the tool leaves the loadout until `/tokenin add`, and nothing is read or run.
9
+ - **`capability_discover({ job })` lets Jev pick the capability.** The agent describes what it is about to do and Jev answers which tool and which skill fit, each possibly `none`. It has its own timeout (`discoverTimeoutMs`, 5 s, capped at 15 s), and the option is left out of the instructions while Jev has no credential.
10
+ - **pi-hermes-memory reads its options from `settings.json`.** The `hermesMemory` object in the global settings takes precedence over the legacy `hermes-memory-config.json`, and consolidation shows progress in the status line.
11
+
12
+ ### Fixed
13
+ - **`ask_jev` paths are checked after resolving symlinks.** A symlink inside the working directory can no longer expose a file outside it, or a secret file under an innocent name.
14
+ - **Agents check capabilities before saying a tool is unavailable.** The capability instruction tells the agent to activate a named tool through `capability_discover` first, and an intercom message received while `intercom` is inactive says how to activate it.
15
+ - **Live tokens-per-second no longer reads ~1 token on Anthropic streams.** The live count takes the larger of the official and estimated counts, because the official count is only final at `message_delta`.
16
+
5
17
  ## [0.13.33] - 2026-09-26
6
18
 
7
19
  ### Added
@@ -52,8 +52,11 @@ export function buildToolCatalog(tools: ToolInfo[], gatewayToolNames: Set<string
52
52
  }
53
53
 
54
54
  export function buildSkillCatalog(skills: ResolvedSkillInfo[]): CatalogEntry[] {
55
+ // The same skill resolves once per source (user dir, package, project); the first one wins, as it
56
+ // does for loading. Duplicates would only spend a bounded Jev request twice on one name.
57
+ const seen = new Set<string>();
55
58
  return skills
56
- .filter((skill) => !skill.disableModelInvocation)
59
+ .filter((skill) => !skill.disableModelInvocation && !seen.has(skill.name) && seen.add(skill.name))
57
60
  .map((skill) => ({
58
61
  name: skill.name,
59
62
  kind: "skill" as const,
@@ -12,6 +12,7 @@ import {
12
12
  toolSummary,
13
13
  type CatalogEntry,
14
14
  } from "./catalog.ts";
15
+ import { capabilityInstruction, describePick, invocationContract, schemaShape } from "./index.ts";
15
16
 
16
17
  const tool = (overrides: Partial<ToolInfo>): ToolInfo =>
17
18
  ({
@@ -67,6 +68,16 @@ describe("catalog metadata", () => {
67
68
  expect(entries[0]!.kind).toBe("skill");
68
69
  });
69
70
 
71
+ it("lists a skill resolved from several sources once, keeping the first", () => {
72
+ const entries = buildSkillCatalog([
73
+ skill({ name: "to-prd", description: "Turn the conversation into a PRD.", category: "docs" }),
74
+ skill({ name: "research" }),
75
+ skill({ name: "to-prd", description: "Older copy." }),
76
+ ]);
77
+ expect(entries.map((e) => e.name)).toEqual(["to-prd", "research"]);
78
+ expect(entries[0]!.category).toBe("docs");
79
+ });
80
+
70
81
  it("falls back to the file frontmatter description when the resolved description is empty", () => {
71
82
  const dir = mkdtempSync(join(tmpdir(), "gw-skill-"));
72
83
  const filePath = join(dir, "SKILL.md");
@@ -85,9 +96,9 @@ describe("catalog metadata", () => {
85
96
 
86
97
  describe("deterministic routing", () => {
87
98
  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" },
99
+ { name: "grep_app_search", kind: "tool", summary: "Search public GitHub code", aliases: ["github-search"], category: "web", eligible: true },
100
+ { name: "grep_app_fetch", kind: "tool", summary: "Fetch a public GitHub file", aliases: [], category: "web", eligible: true },
101
+ { name: "research", kind: "skill", summary: "Investigate a question against primary sources", aliases: [], category: "research", eligible: true },
91
102
  ];
92
103
 
93
104
  it("auto-activates a unique high-confidence tool match", () => {
@@ -105,7 +116,7 @@ describe("deterministic routing", () => {
105
116
  it("auto-activates a uniquely identifiable one-character tool typo", () => {
106
117
  const result = route("activate inercom", [
107
118
  ...catalog,
108
- { name: "intercom", kind: "tool", summary: "Coordinate with other local sessions", aliases: [], category: "coordination" },
119
+ { name: "intercom", kind: "tool", summary: "Coordinate with other local sessions", aliases: [], category: "coordination", eligible: true },
109
120
  ]);
110
121
  expect(result.action).toBe("activate");
111
122
  expect(result.entry?.name).toBe("intercom");
@@ -135,3 +146,81 @@ describe("deterministic routing", () => {
135
146
  expect(a).toEqual(b);
136
147
  });
137
148
  });
149
+
150
+ describe("invocation contract", () => {
151
+ const toolWith = (parameters: unknown, overrides: Partial<ToolInfo> = {}) =>
152
+ tool({ name: "graft_find_code", description: "Find code by question.", parameters: parameters as never, ...overrides });
153
+
154
+ it("lists each parameter with its shape and required flag", () => {
155
+ const contract = invocationContract(
156
+ toolWith({
157
+ type: "object",
158
+ properties: {
159
+ question: { type: "string", description: "What you want to understand, in plain words." },
160
+ limit: { type: "number" },
161
+ },
162
+ required: ["question"],
163
+ }),
164
+ );
165
+ expect(contract).toContain("graft_find_code — Find code by question.");
166
+ expect(contract).toContain("- question (string, required): What you want to understand, in plain words.");
167
+ expect(contract).toContain("- limit (number, optional)");
168
+ });
169
+
170
+ it("names nested shapes instead of expanding them", () => {
171
+ expect(schemaShape({ type: "array", items: { type: "object" } })).toBe("array<object>");
172
+ expect(schemaShape({ anyOf: [{ type: "string" }, { type: "number" }] })).toBe("one of several");
173
+ expect(schemaShape({ enum: ["a", "b"] })).toBe('"a" | "b"');
174
+ expect(schemaShape({})).toBe("value");
175
+ });
176
+
177
+ it("says so when a tool takes no parameters", () => {
178
+ expect(invocationContract(toolWith({ type: "object", properties: {} }))).toContain("Parameters: none");
179
+ });
180
+
181
+ it("clips long text and never carries the schema itself", () => {
182
+ const contract = invocationContract(
183
+ toolWith({
184
+ type: "object",
185
+ properties: { question: { type: "string", description: "x".repeat(500) } },
186
+ required: ["question"],
187
+ }),
188
+ );
189
+ expect(contract).toContain("…");
190
+ expect(contract).not.toContain('"type"');
191
+ expect(contract.split("\n").every((line) => line.length <= 200)).toBe(true);
192
+ });
193
+ });
194
+
195
+ describe("capability instruction", () => {
196
+ it("mentions the job route only when Jev can answer it", () => {
197
+ expect(capabilityInstruction(true)).toContain("capability_discover with `job`");
198
+ const offline = capabilityInstruction(false);
199
+ expect(offline).not.toContain("`job`");
200
+ expect(offline).toContain("capability_catalog");
201
+ expect(offline).toContain("exact `name`");
202
+ });
203
+ });
204
+
205
+ describe("describePick", () => {
206
+ const side = (pick: Parameters<typeof describePick>[1]["pick"], offered = 3, dropped = 0) => ({ pick, offered, dropped });
207
+
208
+ it("reads a hesitant Jev as no confident pick rather than a failure", () => {
209
+ expect(describePick("Skill", side({ selected: false, reason: "low-confidence" }), undefined, "x")).toBe(
210
+ "Skill: no confident pick.",
211
+ );
212
+ expect(describePick("Tool", side({ selected: false, reason: "unquantified" }), undefined, "x")).toBe(
213
+ "Tool: no confident pick.",
214
+ );
215
+ });
216
+
217
+ it("keeps real failures, picks, and an honest none distinct", () => {
218
+ expect(describePick("Tool", side({ selected: false, reason: "timeout" }), undefined, "x")).toBe("Tool: no decision (timeout).");
219
+ expect(describePick("Tool", side({ selected: true, name: "t", confidence: 0.9 }), "t", "x")).toBe(
220
+ 'Tool: Jev picked "t" (confidence 0.90).',
221
+ );
222
+ expect(describePick("Skill", side({ selected: false, reason: "none" }, 40, 9), undefined, "no procedure")).toContain(
223
+ "among 40 of 49 offered",
224
+ );
225
+ });
226
+ });
@@ -10,20 +10,27 @@
10
10
  * tools and Graft's code-context tools: they stay registered but are removed
11
11
  * from the active tool set. Built-in tools are never touched.
12
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.
13
+ * - capability_discover readies exactly one capability. With `name` it validates
14
+ * a catalogued tool and activates its native definition for the current agent
15
+ * run; with `job` the agent describes what it is about to do and Jev answers,
16
+ * from catalog metadata alone, which tool and which skill fit (each possibly
17
+ * none), so the decision costs a bounded request instead of loading every schema. Either way the agent gets the
18
+ * chosen tool's compact invocation contract, and the real schema is live on
19
+ * its next turn. capability_skill_show loads exactly the selected skill
20
+ * instructions.
16
21
  * - A deterministic router activates a uniquely matched tool before the run.
17
22
  * Skills and ambiguous matches remain discoverable through the catalog
18
23
  * without injecting fuzzy hints into the model context.
19
24
  * - Default-on Jev-assisted routing (capabilityGateway.routing.jev in settings.json)
20
- * is only a bounded tie-breaker: when the deterministic router returns an
21
- * ambiguous lexical hint among optional tools, the gateway offers just those
22
- * hinted tools (two or three) and the current prompt to the Jev decisions
23
- * model as one constrained choice question. Jev may answer `none` or one
24
- * hinted canonical tool name; the gateway revalidates the choice against the
25
- * live catalog and activates it for the current run only. No hint, a unique
26
- * activation, a skill match, or an already-activated tool never reaches Jev.
25
+ * has two callers. The host-side one is only a bounded tie-breaker: when the
26
+ * deterministic router returns an ambiguous lexical hint among optional tools,
27
+ * the gateway offers just those hinted tools (two or three) and the current
28
+ * prompt to the Jev decisions model as one constrained choice question. The
29
+ * agent-side one is capability_discover's `job` argument, which offers the
30
+ * catalog metadata of every offered capability. Jev may answer `none` or one
31
+ * canonical name; the gateway revalidates the choice against the live catalog
32
+ * and activates it for the current run only. No hint, a unique activation, a
33
+ * skill match, or an already-activated tool never reaches the host-side path.
27
34
  * Every Jev failure is an ordinary abstention that leaves deterministic
28
35
  * behavior in place. Without Token-In credentials, no Jev request is sent and
29
36
  * the user is prompted to add an account with `/tokenin add`.
@@ -37,7 +44,7 @@
37
44
 
38
45
  import { readFileSync } from "node:fs";
39
46
  import { dirname } from "node:path";
40
- import { stripFrontmatter, type ExtensionAPI, type ToolInfo } from "@selesai/code";
47
+ import { stripFrontmatter, type ExtensionAPI, type ExtensionContext, type ToolInfo } from "@selesai/code";
41
48
  import { StringEnum } from "@earendil-works/pi-ai";
42
49
  import { Text } from "@earendil-works/pi-tui";
43
50
  import { Type } from "typebox";
@@ -45,24 +52,30 @@ import {
45
52
  buildSkillCatalog,
46
53
  buildToolCatalog,
47
54
  BUILTIN_TOOL_NAMES,
55
+ firstSentence,
48
56
  route,
49
57
  type CatalogEntry,
50
58
  } from "./catalog.ts";
51
59
  import {
60
+ gatewayJevConnection,
52
61
  hintedToolCandidates,
53
62
  MIN_GATEWAY_JEV_CANDIDATES,
54
63
  readGatewayJevConfig,
64
+ routeJobToJev,
55
65
  routeToJevTool,
66
+ type JevJobPick,
56
67
  JEV_UNAVAILABLE_REASONS,
57
68
  } from "./routing.ts";
58
- import { confidenceBucket } from "../jev/decisions.ts";
69
+ import { confidenceBucket, jevUnavailable, warnJevUnavailableOnce } from "../jev/decisions.ts";
59
70
 
60
71
  export const GATEWAY_ENV = "SELESAI_CAPABILITY_GATEWAY";
61
72
  export const GATEWAY_TOOLS = new Set(["capability_catalog", "capability_discover", "capability_skill_show"]);
62
73
 
63
74
  // Graft supplies pre-turn hybrid context and must remain callable for precise
64
- // follow-ups; making it dormant defeats both paths.
75
+ // follow-ups; making it dormant defeats both paths. `ask_jev` is the agent's own
76
+ // decision surface for Jev, so it is never something the agent must discover.
65
77
  const ALWAYS_ACTIVE_EXTENSION_TOOLS = new Set([
78
+ "ask_jev",
66
79
  "graft_check_freshness",
67
80
  "graft_file_api",
68
81
  "graft_find_all",
@@ -71,11 +84,88 @@ const ALWAYS_ACTIVE_EXTENSION_TOOLS = new Set([
71
84
  "graft_trace_calls",
72
85
  ]);
73
86
 
74
- export const CAPABILITY_INSTRUCTION = `Optional capabilities (extension tools and skills) are not listed here by default. To use one:
75
- - 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.
76
- - Activate a catalogued tool with capability_discover, then call it normally on the next turn.
77
- - Load a skill's full instructions with capability_skill_show before applying it.
78
- Never invent optional tool names, actions, or fields; discover them first.`;
87
+ const JOB_ROUTE_LINE =
88
+ "- Call capability_discover with `job` (what you are about to do, in your own words) when a task may need a specialized capability you do not have active. Jev answers two things from metadata alone: which tool (callable code, usable many times) and which skill (a written procedure, read once), each possibly none. A chosen tool comes back with its parameters and is callable on your next turn; a chosen skill is loaded with capability_skill_show.";
89
+
90
+ /**
91
+ * The single skill-index entry the gateway installs. The `job` line is offered only while Jev can
92
+ * answer it: telling the agent about a route that always fails costs it a turn every time.
93
+ */
94
+ export function capabilityInstruction(jobRoute: boolean): string {
95
+ return [
96
+ "Optional capabilities (extension tools and skills) are not listed here by default. To use one:",
97
+ ...(jobRoute ? [JOB_ROUTE_LINE] : []),
98
+ jobRoute
99
+ ? '- Call capability_discover with an exact `name`, or search the compact catalog with capability_catalog (kind: "tool" or "skill", natural-language query) when you already know what you are looking for.'
100
+ : '- Search the compact catalog with capability_catalog (kind: "tool" or "skill", natural-language query), then call capability_discover with the exact `name`.',
101
+ "- Load a skill's full instructions with capability_skill_show before applying it.",
102
+ "Never invent optional tool names, actions, or fields; discover them first.",
103
+ "Never tell the user a tool is unavailable, or that you cannot do something, until you have checked for it here: a tool named in a message, instruction, or task that is not in your tool list is usually an optional capability, so call capability_discover with its `name` and use it.",
104
+ ].join("\n");
105
+ }
106
+
107
+ export const CAPABILITY_INSTRUCTION = capabilityInstruction(true);
108
+
109
+ /** Longest description kept in an invocation contract; the full text is live in the schema. */
110
+ export const MAX_CONTRACT_DESCRIPTION_CHARS = 240;
111
+ /** Longest per-parameter description kept in an invocation contract. */
112
+ export const MAX_CONTRACT_PARAMETER_CHARS = 120;
113
+
114
+ function isRecord(value: unknown): value is Record<string, unknown> {
115
+ return typeof value === "object" && value !== null && !Array.isArray(value);
116
+ }
117
+
118
+ /** Gateway tool results: text for the model, plus a shape-only details payload persisted in the session. */
119
+ type ToolAnswer = { content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> };
120
+
121
+ function clip(text: string, max: number): string {
122
+ const trimmed = text.trim();
123
+ return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed;
124
+ }
125
+
126
+ /** A parameter's shape in one token; nested schemas are named, never expanded. */
127
+ export function schemaShape(schema: Record<string, unknown>): string {
128
+ if (Array.isArray(schema.enum)) return schema.enum.map((value) => JSON.stringify(value)).join(" | ");
129
+ if (Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf)) return "one of several";
130
+ if (schema.type === "array") {
131
+ const items = isRecord(schema.items) ? schema.items : {};
132
+ return `array<${typeof items.type === "string" ? items.type : "value"}>`;
133
+ }
134
+ return typeof schema.type === "string" ? schema.type : "value";
135
+ }
136
+
137
+ /**
138
+ * The compact invocation contract for one tool: what it does and the parameters it takes.
139
+ *
140
+ * Deliberately not the schema itself. An activated tool is callable on the next turn, where the
141
+ * provider already carries the full schema, so repeating it here would pay for the same tokens
142
+ * twice. This is enough to plan the call and to abandon it before loading anything.
143
+ */
144
+ export function invocationContract(tool: ToolInfo): string {
145
+ const schema = isRecord(tool.parameters) ? tool.parameters : {};
146
+ const properties = isRecord(schema.properties) ? schema.properties : {};
147
+ const required = new Set(
148
+ Array.isArray(schema.required) ? schema.required.filter((name): name is string => typeof name === "string") : [],
149
+ );
150
+ const lines = [
151
+ `${tool.name} — ${clip(tool.discovery?.summary?.trim() || firstSentence(tool.description) || tool.name, MAX_CONTRACT_DESCRIPTION_CHARS)}`,
152
+ ];
153
+ const parameters = Object.entries(properties);
154
+ if (parameters.length === 0) {
155
+ lines.push("Parameters: none");
156
+ return lines.join("\n");
157
+ }
158
+ lines.push("Parameters:");
159
+ for (const [name, raw] of parameters) {
160
+ const property = isRecord(raw) ? raw : {};
161
+ const description =
162
+ typeof property.description === "string"
163
+ ? `: ${clip(firstSentence(property.description), MAX_CONTRACT_PARAMETER_CHARS)}`
164
+ : "";
165
+ lines.push(`- ${name} (${schemaShape(property)}, ${required.has(name) ? "required" : "optional"})${description}`);
166
+ }
167
+ return lines.join("\n");
168
+ }
79
169
 
80
170
  function isEnabled(): boolean {
81
171
  return process.env[GATEWAY_ENV] !== "0";
@@ -143,6 +233,24 @@ function emitTelemetry(pi: ExtensionAPI, event: string, data: Record<string, unk
143
233
  }
144
234
  }
145
235
 
236
+ /** One kind's line: the pick, `none` (honest about what Jev did not see), or why there is no answer. */
237
+ export function describePick(label: string, side: JevJobPick, chosen: string | undefined, noneMeans: string): string {
238
+ const notSeen =
239
+ side.dropped > 0
240
+ ? ` — among ${side.offered} of ${side.offered + side.dropped} offered; the rest were not considered, so search capability_catalog if one might fit`
241
+ : "";
242
+ if (side.pick.selected) {
243
+ return chosen
244
+ ? `${label}: Jev picked "${chosen}" (confidence ${side.pick.confidence.toFixed(2)}).`
245
+ : `${label}: Jev picked "${side.pick.name}", which is no longer catalogued; nothing was readied.`;
246
+ }
247
+ if (side.pick.reason === "none") return `${label}: none — ${noneMeans}${notSeen}.`;
248
+ if (side.offered === 0) return `${label}: none catalogued.`;
249
+ // Jev leaned somewhere but not firmly enough to act on: for the agent that is "nothing clearly fits".
250
+ if (side.pick.reason === "low-confidence" || side.pick.reason === "unquantified") return `${label}: no confident pick${notSeen}.`;
251
+ return `${label}: no decision (${side.pick.reason}).`;
252
+ }
253
+
146
254
  /** Where a run-local tool activation came from; recorded so use telemetry can attribute it. */
147
255
  type ActivationSource = "deterministic" | "jev" | "discover";
148
256
 
@@ -152,7 +260,22 @@ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
152
260
  // Tools this gateway activated for the current run, and whether they were invoked.
153
261
  // Cleared at agent_settled with the activations themselves.
154
262
  const activations = new Map<string, { source: ActivationSource; used: boolean }>();
155
- let tokenInSetupPrompted = false;
263
+ // Whether the skill index currently offers the `job` route; re-derived before every run.
264
+ let jobRouteOffered = true;
265
+
266
+ /** Replace the eager skill index with one compact capability entry. Full instructions load only on show. */
267
+ function installSkillIndex(): void {
268
+ pi.setSkillsIndexFilter(() => [
269
+ {
270
+ name: "capability-gateway",
271
+ description: capabilityInstruction(jobRouteOffered),
272
+ filePath: "<capability-gateway>",
273
+ baseDir: "<capability-gateway>",
274
+ sourceInfo: { path: "<capability-gateway>", source: "builtin", scope: "user", origin: "top-level" },
275
+ disableModelInvocation: false,
276
+ },
277
+ ]);
278
+ }
156
279
 
157
280
  /** Activate one tool for the current run, keeping the rest of the loadout untouched. */
158
281
  function activateTool(name: string, source: ActivationSource): void {
@@ -173,18 +296,7 @@ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
173
296
  (name) => !eligibleTools(pi).some((tool) => tool.name === name),
174
297
  );
175
298
  pi.setActiveTools(keep);
176
- // Replace the eager skill index with a single compact capability
177
- // instruction entry. Full skill instructions load only on show/invoke.
178
- pi.setSkillsIndexFilter(() => [
179
- {
180
- name: "capability-gateway",
181
- description: CAPABILITY_INSTRUCTION,
182
- filePath: "<capability-gateway>",
183
- baseDir: "<capability-gateway>",
184
- sourceInfo: { path: "<capability-gateway>", source: "builtin", scope: "user", origin: "top-level" },
185
- disableModelInvocation: false,
186
- },
187
- ]);
299
+ installSkillIndex();
188
300
  emitTelemetry(pi, "session_start", { baselineCount: baseline.length, dormantCount: baseline.length - keep.length });
189
301
  void ctx;
190
302
  });
@@ -202,7 +314,7 @@ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
202
314
  query: Type.Optional(Type.String({ minLength: 1, description: "Natural-language query; omit to list all." })),
203
315
  kind: Type.Optional(StringEnum(["tool", "skill"] as const, { description: "Filter by capability kind." })),
204
316
  }),
205
- async execute(_id, params) {
317
+ async execute(_id, params): Promise<ToolAnswer> {
206
318
  const entries = catalogEntries(pi);
207
319
  const filtered = entries.filter(
208
320
  (entry) => !params.kind || entry.kind === params.kind,
@@ -240,39 +352,161 @@ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
240
352
  },
241
353
  });
242
354
 
355
+ /** Make one catalogued tool callable for this run and return how to invoke it. */
356
+ function readyTool(entry: CatalogEntry, source: ActivationSource): string {
357
+ // Already in the loadout (routed before the turn, or discovered earlier): re-activating would
358
+ // only misattribute it, and repeating the contract teaches nothing. Say what the pick means.
359
+ if (pi.getActiveTools().includes(entry.name)) {
360
+ return `"${entry.name}" is already active, so nothing changed. If it failed, the problem is in the tool itself (its error says what), not in choosing it: fix that, or pick a different capability by name.`;
361
+ }
362
+ activateTool(entry.name, source);
363
+ const tool = eligibleTools(pi).find((candidate) => candidate.name === entry.name);
364
+ return [
365
+ `Activated "${entry.name}" for this run: call it on your next turn, with its full schema live in your tool list.`,
366
+ "",
367
+ tool ? invocationContract(tool) : `${entry.name} — ${entry.summary}`,
368
+ ].join("\n");
369
+ }
370
+
371
+ /**
372
+ * The agent's on-demand route. Jev decides over catalog metadata only, so the decision costs one
373
+ * bounded request instead of every schema. A tool and a skill are separate answers: a picked tool
374
+ * is activated with its invocation contract, a picked skill is named for capability_skill_show.
375
+ */
376
+ async function discoverByJob(job: string, kind: "tool" | "skill" | "both", ctx: ExtensionContext): Promise<ToolAnswer> {
377
+ const answer = (text: string, details: Record<string, unknown> = {}): ToolAnswer => ({
378
+ content: [{ type: "text" as const, text }],
379
+ details,
380
+ });
381
+ const config = readGatewayJevConfig();
382
+ if (!config.enabled) {
383
+ return answer(
384
+ "Jev capability routing is off (capabilityGateway.routing.jev.enabled is false). Browse with capability_catalog and pass an exact `name`.",
385
+ );
386
+ }
387
+ const entries = catalogEntries(pi);
388
+ const tools = kind === "skill" ? [] : entries.filter((entry) => entry.kind === "tool");
389
+ const skills = kind === "tool" ? [] : entries.filter((entry) => entry.kind === "skill");
390
+ const route = await routeJobToJev(tools, skills, job, ctx, config);
391
+ if (route.tool.offered + route.skill.offered === 0) {
392
+ return answer(
393
+ "No catalogued capability fits one decision request. Narrow it with capability_catalog and pass an exact `name`.",
394
+ );
395
+ }
396
+
397
+ // Revalidate each accepted name against the live catalog: one that vanished mid-call is gone.
398
+ const live = catalogEntries(pi);
399
+ const resolve = ({ pick }: JevJobPick, of: "tool" | "skill") =>
400
+ pick.selected ? live.find((entry) => entry.kind === of && entry.name === pick.name) : undefined;
401
+ const tool = resolve(route.tool, "tool");
402
+ const skill = resolve(route.skill, "skill");
403
+ // Jev could not be reached at all (as opposed to answering `none`): one reason covers both questions.
404
+ const unavailable = [route.tool.pick, route.skill.pick].flatMap((pick) =>
405
+ !pick.selected && JEV_UNAVAILABLE_REASONS.has(pick.reason) ? [pick.reason] : [],
406
+ )[0];
407
+ emitTelemetry(pi, "route", {
408
+ source: "agent",
409
+ outcome: tool || skill ? "selected" : unavailable ? "unavailable" : "abstained",
410
+ ...(tool ? { tool: tool.name } : {}),
411
+ ...(skill ? { skill: skill.name } : {}),
412
+ candidates: route.tool.offered + route.skill.offered,
413
+ dropped: route.tool.dropped + route.skill.dropped,
414
+ durationMs: route.elapsedMs,
415
+ });
416
+ if (!tool && !skill && (unavailable === "no-credential" || unavailable === "no-template")) {
417
+ if (ctx.hasUI) warnJevUnavailableOnce(ctx.ui, config.provider);
418
+ return answer(
419
+ "Choosing by `job` needs Jev, which has no credential in this session. Search capability_catalog and pass an exact `name` instead, or use the built-in tools.",
420
+ { selected: false, reason: unavailable },
421
+ );
422
+ }
423
+ if (!tool && !skill && unavailable) {
424
+ return answer(
425
+ `Jev could not decide this job (${unavailable}). Browse with capability_catalog and pass an exact \`name\`, or use the built-in tools.`,
426
+ { selected: false, reason: unavailable },
427
+ );
428
+ }
429
+
430
+ const lines: string[] = [];
431
+ const details: Record<string, unknown> = { selected: Boolean(tool || skill) };
432
+ if (kind !== "skill") {
433
+ lines.push(describePick("Tool", route.tool, tool?.name, "the built-in tools (read, bash, edit, write, grep, find, ls) or a direct answer are enough"));
434
+ details.tool = tool?.name ?? null;
435
+ }
436
+ if (kind !== "tool") {
437
+ lines.push(describePick("Skill", route.skill, skill?.name, "no written procedure is needed"));
438
+ if (skill) lines.push(` ${skill.summary}\n Load it with capability_skill_show before applying it.`);
439
+ details.skill = skill?.name ?? null;
440
+ }
441
+ if (tool) lines.push("", readyTool(tool, "jev"));
442
+ return answer(lines.join("\n"), details);
443
+ }
444
+
243
445
  pi.registerTool({
244
446
  name: "capability_discover",
245
447
  label: "Capability Discover",
246
448
  description:
247
- "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.",
248
- promptSnippet: "Activate a catalogued extension tool for the current run",
449
+ "Ready optional capabilities without loading them. Pass `job` (what you are about to do) and Jev answers, from catalog metadata alone, which tool (callable code from an extension or MCP server, usable many times) and which skill (a written procedure from a user, agent, or teammate, read once) fit it — each may be `none`. A chosen tool is activated for this run and its parameters are returned, so you can call it on your next turn; a chosen skill comes back by name for capability_skill_show. Pass `name` instead when capability_catalog already gave you one.",
450
+ promptSnippet: "Let Jev pick the capability for a job (or name one), then see how to invoke it",
249
451
  parameters: Type.Object({
250
- name: Type.String({ minLength: 1, description: "Exact catalogued tool name." }),
452
+ job: Type.Optional(
453
+ Type.String({ minLength: 1, description: "What you are about to do, in your own words. Jev picks the capability." }),
454
+ ),
455
+ name: Type.Optional(Type.String({ minLength: 1, description: "Exact catalogued name, when you already know it." })),
456
+ kind: Type.Optional(
457
+ StringEnum(["tool", "skill", "both"] as const, {
458
+ description: "Ask only about tools or only about skills. Default: both, answered separately.",
459
+ }),
460
+ ),
251
461
  }),
252
- async execute(_id, params) {
462
+ async execute(_id, params, _signal, _onUpdate, ctx: ExtensionContext): Promise<ToolAnswer> {
463
+ const job = params.job?.trim();
464
+ const name = params.name?.trim();
465
+ if (job && name) {
466
+ return {
467
+ content: [{ type: "text", text: "Pass either `job` (let Jev choose) or `name` (you know it), not both." }],
468
+ details: {},
469
+ };
470
+ }
471
+ if (job) return discoverByJob(job, params.kind ?? "both", ctx);
472
+ if (!name) {
473
+ return {
474
+ content: [
475
+ { type: "text", text: "Pass `job` to have Jev choose a capability, or `name` from the catalog." },
476
+ ],
477
+ details: {},
478
+ };
479
+ }
480
+
253
481
  const entries = catalogEntries(pi);
254
- const entry = findEntry(entries, params.name);
255
- if (!entry || entry.kind !== "tool") {
482
+ const entry = findEntry(entries, name);
483
+ if (!entry) {
256
484
  return {
257
485
  content: [
258
486
  {
259
487
  type: "text",
260
- text: `Unknown tool "${params.name}". Search capability_catalog for the exact name, or refine your query.`,
488
+ text: `Unknown capability "${name}". Search capability_catalog for the exact name, or pass a \`job\` and let Jev choose.`,
261
489
  },
262
490
  ],
263
491
  details: { activated: false },
264
492
  };
265
493
  }
266
- activateTool(entry.name, "discover");
267
- emitTelemetry(pi, "discover", { tool: entry.name });
494
+ if (entry.kind === "skill") {
495
+ return {
496
+ content: [
497
+ {
498
+ type: "text",
499
+ text: `"${entry.name}" is a skill: call capability_skill_show with that name to load its instructions.`,
500
+ },
501
+ ],
502
+ details: { activated: false, skill: entry.name },
503
+ };
504
+ }
505
+ emitTelemetry(pi, "discover", { source: "name", tool: entry.name });
506
+ const alreadyActive = pi.getActiveTools().includes(entry.name);
268
507
  return {
269
- content: [
270
- {
271
- type: "text",
272
- text: `Activated "${entry.name}" for this run. Call it normally on the next turn; it is removed when the run ends.`,
273
- },
274
- ],
275
- details: { activated: true, tool: entry.name },
508
+ content: [{ type: "text", text: readyTool(entry, "discover") }],
509
+ details: { activated: true, tool: entry.name, ...(alreadyActive ? { alreadyActive: true } : {}) },
276
510
  };
277
511
  },
278
512
  });
@@ -286,7 +520,7 @@ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
286
520
  parameters: Type.Object({
287
521
  name: Type.String({ minLength: 1, description: "Exact skill name." }),
288
522
  }),
289
- async execute(_id, params) {
523
+ async execute(_id, params): Promise<ToolAnswer> {
290
524
  const skill = pi.getResolvedSkills().find((s) => s.name === params.name);
291
525
  if (!skill) {
292
526
  return {
@@ -324,6 +558,15 @@ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
324
558
  // only a bounded tie-breaker for its ambiguous/hint result.
325
559
  // ------------------------------------------------------------------
326
560
  pi.on("before_agent_start", async (event, ctx) => {
561
+ // Offer the `job` route only while Jev can answer it; `/tokenin add` brings it back on the
562
+ // next run without a reload. Silent: the warning belongs to a path that actually needed Jev.
563
+ const jevConfig = readGatewayJevConfig();
564
+ const jobReady = jevConfig.enabled && (await jevUnavailable(ctx, gatewayJevConnection(jevConfig))) === undefined;
565
+ if (jobReady !== jobRouteOffered) {
566
+ jobRouteOffered = jobReady;
567
+ installSkillIndex();
568
+ }
569
+
327
570
  const entries = catalogEntries(pi);
328
571
  const result = routePrompt(event.prompt, entries);
329
572
  if (result.action === "activate" && result.entry) {
@@ -351,16 +594,8 @@ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
351
594
  emitTelemetry(pi, "route", { source: "jev", outcome: "attempt", candidates: candidates.length });
352
595
  const jevRoute = await routeToJevTool(candidates, event.prompt, ctx, config);
353
596
  if (!jevRoute.selected) {
354
- if (
355
- !tokenInSetupPrompted &&
356
- config.provider === "tokenin" &&
357
- (jevRoute.reason === "no-credential" || jevRoute.reason === "no-template")
358
- ) {
359
- tokenInSetupPrompted = true;
360
- ctx.ui.notify(
361
- "Jev tool tie-breaking needs a Token-In account. Add one with /tokenin add; deterministic routing will keep working meanwhile.",
362
- "warning",
363
- );
597
+ if ((jevRoute.reason === "no-credential" || jevRoute.reason === "no-template") && ctx.hasUI) {
598
+ warnJevUnavailableOnce(ctx.ui, config.provider);
364
599
  }
365
600
  emitTelemetry(pi, "route", {
366
601
  source: "jev",