@selesai/code 0.13.33 → 0.13.35
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 +21 -0
- package/dist/extensions/capability-gateway/catalog.ts +4 -1
- package/dist/extensions/capability-gateway/index.test.ts +93 -4
- package/dist/extensions/capability-gateway/index.ts +295 -59
- package/dist/extensions/capability-gateway/integration.test.ts +35 -0
- package/dist/extensions/capability-gateway/routing.test.ts +154 -1
- package/dist/extensions/capability-gateway/routing.ts +227 -45
- package/dist/extensions/jev/decisions.test.ts +37 -0
- package/dist/extensions/jev/decisions.ts +161 -43
- package/dist/extensions/jev-ask-tool.test.ts +501 -0
- package/dist/extensions/jev-ask-tool.ts +952 -0
- package/dist/extensions/package.json +1 -0
- package/dist/extensions/pi-hermes-memory/README.md +11 -36
- package/dist/extensions/pi-hermes-memory/src/config.ts +36 -8
- package/dist/extensions/pi-hermes-memory/src/constants.ts +5 -5
- package/dist/extensions/pi-hermes-memory/src/handlers/auto-consolidate.ts +60 -46
- package/dist/extensions/pi-hermes-memory/tests/config.test.ts +26 -2
- package/dist/extensions/pi-hermes-memory/tests/handlers/auto-consolidate.test.ts +7 -1
- package/dist/extensions/pi-intercom/index.ts +5 -1
- package/dist/extensions/pi-subagents/src/extension/public-execution.ts +6 -4
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +26 -1
- package/dist/extensions/pi-subagents/src/runs/shared/jev-subagent-routing.ts +255 -0
- package/dist/extensions/pi-subagents/test/unit/jev-subagent-routing.test.ts +117 -0
- package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +3 -1
- package/dist/extensions/rtk.test.ts +21 -13
- package/dist/extensions/tps.test.ts +32 -1
- package/dist/extensions/tps.ts +3 -1
- package/docs/settings.md +64 -7
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@selesai/code` will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.13.35] - 2026-09-29
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **`jev_find` finds files by what they do.** ripgrep gathers up to 48 candidate files (matching `pattern`, or listed under `path`/`glob` and ranked by question words), Jev judges each file's best-matching lines against a plain-words `question` in parallel batches of 16, and the agent gets ranked `path:lines (relevance)` pointers instead of file contents. It respects `.gitignore`, stays inside the working directory, skips secret-named files, and returns the unranked candidates when Jev is unavailable. It ships with `ask_jev` under `jevAdvisory.routes.ask`, stays active under the capability gateway, and leaves the loadout while Jev has no credential.
|
|
9
|
+
- **Jev can set up a subagent launch (opt-in).** With `jevAdvisory.routes.subagent.enabled`, Jev fills only what the parent left open on a single-child `subagent` call: it picks an agent by function when `agent` is omitted or generic (`genericAgents`, default `["delegate"]`), may drop tools from that agent's declared list (never `read` or supervision tools), and picks a `simple`/`complex`/`reasoning` model from `tiers` when no `model` was passed. Any abstention launches exactly what was asked.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- **The agent reaches for Jev when locating code.** The `jev_find` guidance tells the agent to use it before grep, find, or reading candidate files when it searches by behavior rather than an exact identifier; exact-name lookups still go to grep.
|
|
13
|
+
|
|
14
|
+
## [0.13.34] - 2026-09-29
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **`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.
|
|
18
|
+
- **`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.
|
|
19
|
+
- **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.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **`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.
|
|
23
|
+
- **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.
|
|
24
|
+
- **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`.
|
|
25
|
+
|
|
5
26
|
## [0.13.33] - 2026-09-26
|
|
6
27
|
|
|
7
28
|
### 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
|
|
14
|
-
* definition for the current agent
|
|
15
|
-
* the
|
|
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
|
|
21
|
-
* ambiguous lexical hint among optional tools,
|
|
22
|
-
* hinted tools (two or three) and the current
|
|
23
|
-
* model as one constrained choice question.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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,31 @@ 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 (and `jev_find` its file finder), so neither is something the agent must discover.
|
|
65
77
|
const ALWAYS_ACTIVE_EXTENSION_TOOLS = new Set([
|
|
78
|
+
"ask_jev",
|
|
79
|
+
"jev_find",
|
|
66
80
|
"graft_check_freshness",
|
|
67
81
|
"graft_file_api",
|
|
68
82
|
"graft_find_all",
|
|
@@ -71,11 +85,88 @@ const ALWAYS_ACTIVE_EXTENSION_TOOLS = new Set([
|
|
|
71
85
|
"graft_trace_calls",
|
|
72
86
|
]);
|
|
73
87
|
|
|
74
|
-
|
|
75
|
-
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
88
|
+
const JOB_ROUTE_LINE =
|
|
89
|
+
"- 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.";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The single skill-index entry the gateway installs. The `job` line is offered only while Jev can
|
|
93
|
+
* answer it: telling the agent about a route that always fails costs it a turn every time.
|
|
94
|
+
*/
|
|
95
|
+
export function capabilityInstruction(jobRoute: boolean): string {
|
|
96
|
+
return [
|
|
97
|
+
"Optional capabilities (extension tools and skills) are not listed here by default. To use one:",
|
|
98
|
+
...(jobRoute ? [JOB_ROUTE_LINE] : []),
|
|
99
|
+
jobRoute
|
|
100
|
+
? '- 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.'
|
|
101
|
+
: '- Search the compact catalog with capability_catalog (kind: "tool" or "skill", natural-language query), then call capability_discover with the exact `name`.',
|
|
102
|
+
"- Load a skill's full instructions with capability_skill_show before applying it.",
|
|
103
|
+
"Never invent optional tool names, actions, or fields; discover them first.",
|
|
104
|
+
"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.",
|
|
105
|
+
].join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const CAPABILITY_INSTRUCTION = capabilityInstruction(true);
|
|
109
|
+
|
|
110
|
+
/** Longest description kept in an invocation contract; the full text is live in the schema. */
|
|
111
|
+
export const MAX_CONTRACT_DESCRIPTION_CHARS = 240;
|
|
112
|
+
/** Longest per-parameter description kept in an invocation contract. */
|
|
113
|
+
export const MAX_CONTRACT_PARAMETER_CHARS = 120;
|
|
114
|
+
|
|
115
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
116
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Gateway tool results: text for the model, plus a shape-only details payload persisted in the session. */
|
|
120
|
+
type ToolAnswer = { content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> };
|
|
121
|
+
|
|
122
|
+
function clip(text: string, max: number): string {
|
|
123
|
+
const trimmed = text.trim();
|
|
124
|
+
return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A parameter's shape in one token; nested schemas are named, never expanded. */
|
|
128
|
+
export function schemaShape(schema: Record<string, unknown>): string {
|
|
129
|
+
if (Array.isArray(schema.enum)) return schema.enum.map((value) => JSON.stringify(value)).join(" | ");
|
|
130
|
+
if (Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf)) return "one of several";
|
|
131
|
+
if (schema.type === "array") {
|
|
132
|
+
const items = isRecord(schema.items) ? schema.items : {};
|
|
133
|
+
return `array<${typeof items.type === "string" ? items.type : "value"}>`;
|
|
134
|
+
}
|
|
135
|
+
return typeof schema.type === "string" ? schema.type : "value";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The compact invocation contract for one tool: what it does and the parameters it takes.
|
|
140
|
+
*
|
|
141
|
+
* Deliberately not the schema itself. An activated tool is callable on the next turn, where the
|
|
142
|
+
* provider already carries the full schema, so repeating it here would pay for the same tokens
|
|
143
|
+
* twice. This is enough to plan the call and to abandon it before loading anything.
|
|
144
|
+
*/
|
|
145
|
+
export function invocationContract(tool: ToolInfo): string {
|
|
146
|
+
const schema = isRecord(tool.parameters) ? tool.parameters : {};
|
|
147
|
+
const properties = isRecord(schema.properties) ? schema.properties : {};
|
|
148
|
+
const required = new Set(
|
|
149
|
+
Array.isArray(schema.required) ? schema.required.filter((name): name is string => typeof name === "string") : [],
|
|
150
|
+
);
|
|
151
|
+
const lines = [
|
|
152
|
+
`${tool.name} — ${clip(tool.discovery?.summary?.trim() || firstSentence(tool.description) || tool.name, MAX_CONTRACT_DESCRIPTION_CHARS)}`,
|
|
153
|
+
];
|
|
154
|
+
const parameters = Object.entries(properties);
|
|
155
|
+
if (parameters.length === 0) {
|
|
156
|
+
lines.push("Parameters: none");
|
|
157
|
+
return lines.join("\n");
|
|
158
|
+
}
|
|
159
|
+
lines.push("Parameters:");
|
|
160
|
+
for (const [name, raw] of parameters) {
|
|
161
|
+
const property = isRecord(raw) ? raw : {};
|
|
162
|
+
const description =
|
|
163
|
+
typeof property.description === "string"
|
|
164
|
+
? `: ${clip(firstSentence(property.description), MAX_CONTRACT_PARAMETER_CHARS)}`
|
|
165
|
+
: "";
|
|
166
|
+
lines.push(`- ${name} (${schemaShape(property)}, ${required.has(name) ? "required" : "optional"})${description}`);
|
|
167
|
+
}
|
|
168
|
+
return lines.join("\n");
|
|
169
|
+
}
|
|
79
170
|
|
|
80
171
|
function isEnabled(): boolean {
|
|
81
172
|
return process.env[GATEWAY_ENV] !== "0";
|
|
@@ -143,6 +234,24 @@ function emitTelemetry(pi: ExtensionAPI, event: string, data: Record<string, unk
|
|
|
143
234
|
}
|
|
144
235
|
}
|
|
145
236
|
|
|
237
|
+
/** One kind's line: the pick, `none` (honest about what Jev did not see), or why there is no answer. */
|
|
238
|
+
export function describePick(label: string, side: JevJobPick, chosen: string | undefined, noneMeans: string): string {
|
|
239
|
+
const notSeen =
|
|
240
|
+
side.dropped > 0
|
|
241
|
+
? ` — among ${side.offered} of ${side.offered + side.dropped} offered; the rest were not considered, so search capability_catalog if one might fit`
|
|
242
|
+
: "";
|
|
243
|
+
if (side.pick.selected) {
|
|
244
|
+
return chosen
|
|
245
|
+
? `${label}: Jev picked "${chosen}" (confidence ${side.pick.confidence.toFixed(2)}).`
|
|
246
|
+
: `${label}: Jev picked "${side.pick.name}", which is no longer catalogued; nothing was readied.`;
|
|
247
|
+
}
|
|
248
|
+
if (side.pick.reason === "none") return `${label}: none — ${noneMeans}${notSeen}.`;
|
|
249
|
+
if (side.offered === 0) return `${label}: none catalogued.`;
|
|
250
|
+
// Jev leaned somewhere but not firmly enough to act on: for the agent that is "nothing clearly fits".
|
|
251
|
+
if (side.pick.reason === "low-confidence" || side.pick.reason === "unquantified") return `${label}: no confident pick${notSeen}.`;
|
|
252
|
+
return `${label}: no decision (${side.pick.reason}).`;
|
|
253
|
+
}
|
|
254
|
+
|
|
146
255
|
/** Where a run-local tool activation came from; recorded so use telemetry can attribute it. */
|
|
147
256
|
type ActivationSource = "deterministic" | "jev" | "discover";
|
|
148
257
|
|
|
@@ -152,7 +261,22 @@ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
|
|
|
152
261
|
// Tools this gateway activated for the current run, and whether they were invoked.
|
|
153
262
|
// Cleared at agent_settled with the activations themselves.
|
|
154
263
|
const activations = new Map<string, { source: ActivationSource; used: boolean }>();
|
|
155
|
-
|
|
264
|
+
// Whether the skill index currently offers the `job` route; re-derived before every run.
|
|
265
|
+
let jobRouteOffered = true;
|
|
266
|
+
|
|
267
|
+
/** Replace the eager skill index with one compact capability entry. Full instructions load only on show. */
|
|
268
|
+
function installSkillIndex(): void {
|
|
269
|
+
pi.setSkillsIndexFilter(() => [
|
|
270
|
+
{
|
|
271
|
+
name: "capability-gateway",
|
|
272
|
+
description: capabilityInstruction(jobRouteOffered),
|
|
273
|
+
filePath: "<capability-gateway>",
|
|
274
|
+
baseDir: "<capability-gateway>",
|
|
275
|
+
sourceInfo: { path: "<capability-gateway>", source: "builtin", scope: "user", origin: "top-level" },
|
|
276
|
+
disableModelInvocation: false,
|
|
277
|
+
},
|
|
278
|
+
]);
|
|
279
|
+
}
|
|
156
280
|
|
|
157
281
|
/** Activate one tool for the current run, keeping the rest of the loadout untouched. */
|
|
158
282
|
function activateTool(name: string, source: ActivationSource): void {
|
|
@@ -173,18 +297,7 @@ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
|
|
|
173
297
|
(name) => !eligibleTools(pi).some((tool) => tool.name === name),
|
|
174
298
|
);
|
|
175
299
|
pi.setActiveTools(keep);
|
|
176
|
-
|
|
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
|
-
]);
|
|
300
|
+
installSkillIndex();
|
|
188
301
|
emitTelemetry(pi, "session_start", { baselineCount: baseline.length, dormantCount: baseline.length - keep.length });
|
|
189
302
|
void ctx;
|
|
190
303
|
});
|
|
@@ -202,7 +315,7 @@ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
|
|
|
202
315
|
query: Type.Optional(Type.String({ minLength: 1, description: "Natural-language query; omit to list all." })),
|
|
203
316
|
kind: Type.Optional(StringEnum(["tool", "skill"] as const, { description: "Filter by capability kind." })),
|
|
204
317
|
}),
|
|
205
|
-
async execute(_id, params) {
|
|
318
|
+
async execute(_id, params): Promise<ToolAnswer> {
|
|
206
319
|
const entries = catalogEntries(pi);
|
|
207
320
|
const filtered = entries.filter(
|
|
208
321
|
(entry) => !params.kind || entry.kind === params.kind,
|
|
@@ -240,39 +353,161 @@ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
|
|
|
240
353
|
},
|
|
241
354
|
});
|
|
242
355
|
|
|
356
|
+
/** Make one catalogued tool callable for this run and return how to invoke it. */
|
|
357
|
+
function readyTool(entry: CatalogEntry, source: ActivationSource): string {
|
|
358
|
+
// Already in the loadout (routed before the turn, or discovered earlier): re-activating would
|
|
359
|
+
// only misattribute it, and repeating the contract teaches nothing. Say what the pick means.
|
|
360
|
+
if (pi.getActiveTools().includes(entry.name)) {
|
|
361
|
+
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.`;
|
|
362
|
+
}
|
|
363
|
+
activateTool(entry.name, source);
|
|
364
|
+
const tool = eligibleTools(pi).find((candidate) => candidate.name === entry.name);
|
|
365
|
+
return [
|
|
366
|
+
`Activated "${entry.name}" for this run: call it on your next turn, with its full schema live in your tool list.`,
|
|
367
|
+
"",
|
|
368
|
+
tool ? invocationContract(tool) : `${entry.name} — ${entry.summary}`,
|
|
369
|
+
].join("\n");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The agent's on-demand route. Jev decides over catalog metadata only, so the decision costs one
|
|
374
|
+
* bounded request instead of every schema. A tool and a skill are separate answers: a picked tool
|
|
375
|
+
* is activated with its invocation contract, a picked skill is named for capability_skill_show.
|
|
376
|
+
*/
|
|
377
|
+
async function discoverByJob(job: string, kind: "tool" | "skill" | "both", ctx: ExtensionContext): Promise<ToolAnswer> {
|
|
378
|
+
const answer = (text: string, details: Record<string, unknown> = {}): ToolAnswer => ({
|
|
379
|
+
content: [{ type: "text" as const, text }],
|
|
380
|
+
details,
|
|
381
|
+
});
|
|
382
|
+
const config = readGatewayJevConfig();
|
|
383
|
+
if (!config.enabled) {
|
|
384
|
+
return answer(
|
|
385
|
+
"Jev capability routing is off (capabilityGateway.routing.jev.enabled is false). Browse with capability_catalog and pass an exact `name`.",
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
const entries = catalogEntries(pi);
|
|
389
|
+
const tools = kind === "skill" ? [] : entries.filter((entry) => entry.kind === "tool");
|
|
390
|
+
const skills = kind === "tool" ? [] : entries.filter((entry) => entry.kind === "skill");
|
|
391
|
+
const route = await routeJobToJev(tools, skills, job, ctx, config);
|
|
392
|
+
if (route.tool.offered + route.skill.offered === 0) {
|
|
393
|
+
return answer(
|
|
394
|
+
"No catalogued capability fits one decision request. Narrow it with capability_catalog and pass an exact `name`.",
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Revalidate each accepted name against the live catalog: one that vanished mid-call is gone.
|
|
399
|
+
const live = catalogEntries(pi);
|
|
400
|
+
const resolve = ({ pick }: JevJobPick, of: "tool" | "skill") =>
|
|
401
|
+
pick.selected ? live.find((entry) => entry.kind === of && entry.name === pick.name) : undefined;
|
|
402
|
+
const tool = resolve(route.tool, "tool");
|
|
403
|
+
const skill = resolve(route.skill, "skill");
|
|
404
|
+
// Jev could not be reached at all (as opposed to answering `none`): one reason covers both questions.
|
|
405
|
+
const unavailable = [route.tool.pick, route.skill.pick].flatMap((pick) =>
|
|
406
|
+
!pick.selected && JEV_UNAVAILABLE_REASONS.has(pick.reason) ? [pick.reason] : [],
|
|
407
|
+
)[0];
|
|
408
|
+
emitTelemetry(pi, "route", {
|
|
409
|
+
source: "agent",
|
|
410
|
+
outcome: tool || skill ? "selected" : unavailable ? "unavailable" : "abstained",
|
|
411
|
+
...(tool ? { tool: tool.name } : {}),
|
|
412
|
+
...(skill ? { skill: skill.name } : {}),
|
|
413
|
+
candidates: route.tool.offered + route.skill.offered,
|
|
414
|
+
dropped: route.tool.dropped + route.skill.dropped,
|
|
415
|
+
durationMs: route.elapsedMs,
|
|
416
|
+
});
|
|
417
|
+
if (!tool && !skill && (unavailable === "no-credential" || unavailable === "no-template")) {
|
|
418
|
+
if (ctx.hasUI) warnJevUnavailableOnce(ctx.ui, config.provider);
|
|
419
|
+
return answer(
|
|
420
|
+
"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.",
|
|
421
|
+
{ selected: false, reason: unavailable },
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
if (!tool && !skill && unavailable) {
|
|
425
|
+
return answer(
|
|
426
|
+
`Jev could not decide this job (${unavailable}). Browse with capability_catalog and pass an exact \`name\`, or use the built-in tools.`,
|
|
427
|
+
{ selected: false, reason: unavailable },
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const lines: string[] = [];
|
|
432
|
+
const details: Record<string, unknown> = { selected: Boolean(tool || skill) };
|
|
433
|
+
if (kind !== "skill") {
|
|
434
|
+
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"));
|
|
435
|
+
details.tool = tool?.name ?? null;
|
|
436
|
+
}
|
|
437
|
+
if (kind !== "tool") {
|
|
438
|
+
lines.push(describePick("Skill", route.skill, skill?.name, "no written procedure is needed"));
|
|
439
|
+
if (skill) lines.push(` ${skill.summary}\n Load it with capability_skill_show before applying it.`);
|
|
440
|
+
details.skill = skill?.name ?? null;
|
|
441
|
+
}
|
|
442
|
+
if (tool) lines.push("", readyTool(tool, "jev"));
|
|
443
|
+
return answer(lines.join("\n"), details);
|
|
444
|
+
}
|
|
445
|
+
|
|
243
446
|
pi.registerTool({
|
|
244
447
|
name: "capability_discover",
|
|
245
448
|
label: "Capability Discover",
|
|
246
449
|
description:
|
|
247
|
-
"
|
|
248
|
-
promptSnippet: "
|
|
450
|
+
"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.",
|
|
451
|
+
promptSnippet: "Let Jev pick the capability for a job (or name one), then see how to invoke it",
|
|
249
452
|
parameters: Type.Object({
|
|
250
|
-
|
|
453
|
+
job: Type.Optional(
|
|
454
|
+
Type.String({ minLength: 1, description: "What you are about to do, in your own words. Jev picks the capability." }),
|
|
455
|
+
),
|
|
456
|
+
name: Type.Optional(Type.String({ minLength: 1, description: "Exact catalogued name, when you already know it." })),
|
|
457
|
+
kind: Type.Optional(
|
|
458
|
+
StringEnum(["tool", "skill", "both"] as const, {
|
|
459
|
+
description: "Ask only about tools or only about skills. Default: both, answered separately.",
|
|
460
|
+
}),
|
|
461
|
+
),
|
|
251
462
|
}),
|
|
252
|
-
async execute(_id, params) {
|
|
463
|
+
async execute(_id, params, _signal, _onUpdate, ctx: ExtensionContext): Promise<ToolAnswer> {
|
|
464
|
+
const job = params.job?.trim();
|
|
465
|
+
const name = params.name?.trim();
|
|
466
|
+
if (job && name) {
|
|
467
|
+
return {
|
|
468
|
+
content: [{ type: "text", text: "Pass either `job` (let Jev choose) or `name` (you know it), not both." }],
|
|
469
|
+
details: {},
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
if (job) return discoverByJob(job, params.kind ?? "both", ctx);
|
|
473
|
+
if (!name) {
|
|
474
|
+
return {
|
|
475
|
+
content: [
|
|
476
|
+
{ type: "text", text: "Pass `job` to have Jev choose a capability, or `name` from the catalog." },
|
|
477
|
+
],
|
|
478
|
+
details: {},
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
253
482
|
const entries = catalogEntries(pi);
|
|
254
|
-
const entry = findEntry(entries,
|
|
255
|
-
if (!entry
|
|
483
|
+
const entry = findEntry(entries, name);
|
|
484
|
+
if (!entry) {
|
|
256
485
|
return {
|
|
257
486
|
content: [
|
|
258
487
|
{
|
|
259
488
|
type: "text",
|
|
260
|
-
text: `Unknown
|
|
489
|
+
text: `Unknown capability "${name}". Search capability_catalog for the exact name, or pass a \`job\` and let Jev choose.`,
|
|
261
490
|
},
|
|
262
491
|
],
|
|
263
492
|
details: { activated: false },
|
|
264
493
|
};
|
|
265
494
|
}
|
|
266
|
-
|
|
267
|
-
|
|
495
|
+
if (entry.kind === "skill") {
|
|
496
|
+
return {
|
|
497
|
+
content: [
|
|
498
|
+
{
|
|
499
|
+
type: "text",
|
|
500
|
+
text: `"${entry.name}" is a skill: call capability_skill_show with that name to load its instructions.`,
|
|
501
|
+
},
|
|
502
|
+
],
|
|
503
|
+
details: { activated: false, skill: entry.name },
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
emitTelemetry(pi, "discover", { source: "name", tool: entry.name });
|
|
507
|
+
const alreadyActive = pi.getActiveTools().includes(entry.name);
|
|
268
508
|
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 },
|
|
509
|
+
content: [{ type: "text", text: readyTool(entry, "discover") }],
|
|
510
|
+
details: { activated: true, tool: entry.name, ...(alreadyActive ? { alreadyActive: true } : {}) },
|
|
276
511
|
};
|
|
277
512
|
},
|
|
278
513
|
});
|
|
@@ -286,7 +521,7 @@ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
|
|
|
286
521
|
parameters: Type.Object({
|
|
287
522
|
name: Type.String({ minLength: 1, description: "Exact skill name." }),
|
|
288
523
|
}),
|
|
289
|
-
async execute(_id, params) {
|
|
524
|
+
async execute(_id, params): Promise<ToolAnswer> {
|
|
290
525
|
const skill = pi.getResolvedSkills().find((s) => s.name === params.name);
|
|
291
526
|
if (!skill) {
|
|
292
527
|
return {
|
|
@@ -324,6 +559,15 @@ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
|
|
|
324
559
|
// only a bounded tie-breaker for its ambiguous/hint result.
|
|
325
560
|
// ------------------------------------------------------------------
|
|
326
561
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
562
|
+
// Offer the `job` route only while Jev can answer it; `/tokenin add` brings it back on the
|
|
563
|
+
// next run without a reload. Silent: the warning belongs to a path that actually needed Jev.
|
|
564
|
+
const jevConfig = readGatewayJevConfig();
|
|
565
|
+
const jobReady = jevConfig.enabled && (await jevUnavailable(ctx, gatewayJevConnection(jevConfig))) === undefined;
|
|
566
|
+
if (jobReady !== jobRouteOffered) {
|
|
567
|
+
jobRouteOffered = jobReady;
|
|
568
|
+
installSkillIndex();
|
|
569
|
+
}
|
|
570
|
+
|
|
327
571
|
const entries = catalogEntries(pi);
|
|
328
572
|
const result = routePrompt(event.prompt, entries);
|
|
329
573
|
if (result.action === "activate" && result.entry) {
|
|
@@ -351,16 +595,8 @@ export default function capabilityGatewayExtension(pi: ExtensionAPI): void {
|
|
|
351
595
|
emitTelemetry(pi, "route", { source: "jev", outcome: "attempt", candidates: candidates.length });
|
|
352
596
|
const jevRoute = await routeToJevTool(candidates, event.prompt, ctx, config);
|
|
353
597
|
if (!jevRoute.selected) {
|
|
354
|
-
if (
|
|
355
|
-
|
|
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
|
-
);
|
|
598
|
+
if ((jevRoute.reason === "no-credential" || jevRoute.reason === "no-template") && ctx.hasUI) {
|
|
599
|
+
warnJevUnavailableOnce(ctx.ui, config.provider);
|
|
364
600
|
}
|
|
365
601
|
emitTelemetry(pi, "route", {
|
|
366
602
|
source: "jev",
|