@selesai/code 0.13.34 → 0.13.37
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 +22 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -0
- package/dist/core/remote-catalog-provider.js +2 -2
- package/dist/core/remote-catalog-provider.test.js +20 -1
- package/dist/extensions/capability-gateway/index.ts +2 -1
- package/dist/extensions/jev/decisions.ts +14 -1
- package/dist/extensions/jev-ask-tool.test.ts +70 -5
- package/dist/extensions/jev-ask-tool.ts +375 -10
- 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/ponytail/index.js +7 -4
- package/dist/extensions/ponytail/ponytail-config.cjs +52 -26
- package/dist/extensions/ponytail/ponytail-instructions.cjs +3 -17
- package/dist/extensions/ponytail/test/extension.test.js +37 -16
- package/dist/extensions/ponytail/test/helpers.test.js +54 -51
- package/dist/skills/ponytail/SKILL.md +5 -7
- package/dist/skills/ponytail-help/SKILL.md +10 -14
- package/docs/settings.md +22 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@selesai/code` will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.13.37] - 2026-09-30
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- **Ponytail starts in `lite` by default.** With no `PONYTAIL_DEFAULT_MODE` and no `ponytailMode` in the agent `settings.json`, new sessions and handoffs now start in `lite` instead of `off`. A saved choice still wins: `/ponytail off` (or `/ponytail default off`) keeps it off.
|
|
9
|
+
|
|
10
|
+
## [0.13.36] - 2026-09-29
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Remote model catalogs load again.** The pi.dev catalog request identified itself as `pi/<selesai version>`, and pi.dev answers an unknown Pi version with 404 ("No compatible model catalog is available for this Pi version"). Selesai cached that as an empty catalog, so models newer than the bundled data — such as `openai-codex/gpt-6-sol`, `openai-codex/gpt-6-luna`, and `github-copilot/gpt-6-luna` — showed as `[unavailable]` in the model list even when logged in. The request now carries the upstream Pi platform version (`PI_PLATFORM_VERSION`, the pinned `@earendil-works/pi-ai` release). An existing empty entry in `models-store.json` is refetched after its 4-hour window, or immediately if you delete it.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- **Ponytail is off by default and remembers your mode.** Opt in with `/ponytail`. `/ponytail <mode>` saves the choice as `ponytailMode` in the agent `settings.json`, so new sessions and handoffs start in it; other keys are preserved and an unparseable `settings.json` is never overwritten. Modes are `off`, `lite`, and `full` (`ultra` was removed), and every mode now gets upstream's full ponytail prompt.
|
|
17
|
+
|
|
18
|
+
## [0.13.35] - 2026-09-29
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- **`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.
|
|
22
|
+
- **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.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- **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.
|
|
26
|
+
|
|
5
27
|
## [0.13.34] - 2026-09-29
|
|
6
28
|
|
|
7
29
|
### Added
|
package/dist/config.d.ts
CHANGED
|
@@ -92,6 +92,8 @@ export declare const APP_NAME: string;
|
|
|
92
92
|
export declare const APP_TITLE: string;
|
|
93
93
|
export declare const CONFIG_DIR_NAME: string;
|
|
94
94
|
export declare const VERSION: string;
|
|
95
|
+
/** Upstream Pi platform version (the pinned pi-ai release). pi.dev gates model catalogs on this, not on VERSION. */
|
|
96
|
+
export declare const PI_PLATFORM_VERSION: string;
|
|
95
97
|
export declare const ENV_AGENT_DIR: string;
|
|
96
98
|
export declare const ENV_SESSION_DIR: string;
|
|
97
99
|
export declare function expandTildePath(path: string): string;
|
package/dist/config.js
CHANGED
|
@@ -463,6 +463,8 @@ export const APP_NAME = piConfigName || "pi";
|
|
|
463
463
|
export const APP_TITLE = piConfigName ? APP_NAME : "π";
|
|
464
464
|
export const CONFIG_DIR_NAME = pkg.piConfig?.configDir || ".pi";
|
|
465
465
|
export const VERSION = pkg.version || "0.0.0";
|
|
466
|
+
/** Upstream Pi platform version (the pinned pi-ai release). pi.dev gates model catalogs on this, not on VERSION. */
|
|
467
|
+
export const PI_PLATFORM_VERSION = pkg.dependencies?.["@earendil-works/pi-ai"]?.replace(/^[^\d]*/, "") || VERSION;
|
|
466
468
|
// e.g., PI_CODING_AGENT_DIR or TAU_CODING_AGENT_DIR
|
|
467
469
|
export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`;
|
|
468
470
|
export const ENV_SESSION_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_SESSION_DIR`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { PI_PLATFORM_VERSION } from "../config.js";
|
|
2
2
|
import { getPiUserAgent } from "../utils/pi-user-agent.js";
|
|
3
3
|
const DEFAULT_CATALOG_BASE_URL = "https://pi.dev";
|
|
4
4
|
export const REMOTE_CATALOG_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000;
|
|
@@ -70,7 +70,7 @@ export function withRemoteCatalog(provider, catalogBaseUrl = DEFAULT_CATALOG_BAS
|
|
|
70
70
|
const response = await fetch(url, {
|
|
71
71
|
headers: {
|
|
72
72
|
accept: "application/json",
|
|
73
|
-
"User-Agent": getPiUserAgent(
|
|
73
|
+
"User-Agent": getPiUserAgent(PI_PLATFORM_VERSION),
|
|
74
74
|
...(validator ? { "if-none-match": validator } : {}),
|
|
75
75
|
},
|
|
76
76
|
signal: context.signal,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { PI_PLATFORM_VERSION, VERSION } from "../config.js";
|
|
2
3
|
import { withRemoteCatalog } from "./remote-catalog-provider.js";
|
|
3
4
|
const cachedModel = {
|
|
4
5
|
id: "gpt-6-sol",
|
|
@@ -26,4 +27,22 @@ describe("remote model catalog cache", () => {
|
|
|
26
27
|
await wrapped.refreshModels?.(refreshContext({ models: [cachedModel], checkedAt: localGeneratedAt + 1, lastModified: 0 }));
|
|
27
28
|
expect(wrapped.getModels().map((model) => model.id)).toEqual(["gpt-6-sol"]);
|
|
28
29
|
});
|
|
30
|
+
// pi.dev 404s catalogs for unknown Pi versions; selesai's own VERSION is not a Pi version.
|
|
31
|
+
it("identifies as the upstream Pi platform version, not the selesai version", async () => {
|
|
32
|
+
expect(PI_PLATFORM_VERSION).not.toBe(VERSION);
|
|
33
|
+
const fetchMock = vi.fn(async () => new Response("{}", { status: 200 }));
|
|
34
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
35
|
+
try {
|
|
36
|
+
const provider = { id: "openai-codex", getModels: () => [] };
|
|
37
|
+
await withRemoteCatalog(provider, "https://example.test").refreshModels?.({
|
|
38
|
+
...refreshContext(undefined),
|
|
39
|
+
allowNetwork: true,
|
|
40
|
+
});
|
|
41
|
+
const headers = fetchMock.mock.calls[0][1].headers;
|
|
42
|
+
expect(headers["User-Agent"]).toMatch(new RegExp(`^pi/${PI_PLATFORM_VERSION.replaceAll(".", "\\.")} `));
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
vi.unstubAllGlobals();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
29
48
|
});
|
|
@@ -73,9 +73,10 @@ export const GATEWAY_TOOLS = new Set(["capability_catalog", "capability_discover
|
|
|
73
73
|
|
|
74
74
|
// Graft supplies pre-turn hybrid context and must remain callable for precise
|
|
75
75
|
// follow-ups; making it dormant defeats both paths. `ask_jev` is the agent's own
|
|
76
|
-
// decision surface for Jev, so
|
|
76
|
+
// decision surface for Jev (and `jev_find` its file finder), so neither is something the agent must discover.
|
|
77
77
|
const ALWAYS_ACTIVE_EXTENSION_TOOLS = new Set([
|
|
78
78
|
"ask_jev",
|
|
79
|
+
"jev_find",
|
|
79
80
|
"graft_check_freshness",
|
|
80
81
|
"graft_file_api",
|
|
81
82
|
"graft_find_all",
|
|
@@ -28,7 +28,7 @@ import type { AssistantMessage, Context, Model } from "@earendil-works/pi-ai";
|
|
|
28
28
|
// Advisory configuration (one opt-in area, per-route enablement)
|
|
29
29
|
// ---------------------------------------------------------------------------
|
|
30
30
|
|
|
31
|
-
export const JEV_ROUTE_NAMES = ["memory", "recommendations", "ask"] as const;
|
|
31
|
+
export const JEV_ROUTE_NAMES = ["memory", "recommendations", "ask", "subagent"] as const;
|
|
32
32
|
export type JevRouteName = (typeof JEV_ROUTE_NAMES)[number];
|
|
33
33
|
|
|
34
34
|
export interface JevRouteConfig {
|
|
@@ -83,6 +83,17 @@ export const DEFAULT_JEV_ASK_ROUTE_CONFIG: JevRouteConfig = {
|
|
|
83
83
|
payloadBytes: 32 * 1024,
|
|
84
84
|
};
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The `subagent` route sets up a public single-child launch (agent, tools, model tier; see
|
|
88
|
+
* pi-subagents' jev-subagent-routing.ts). Opt-in, and it sits on the launch path, so its
|
|
89
|
+
* deadline is shorter than the ask route's; its state is the task plus agent descriptions.
|
|
90
|
+
*/
|
|
91
|
+
export const DEFAULT_JEV_SUBAGENT_ROUTE_CONFIG: JevRouteConfig = {
|
|
92
|
+
...DEFAULT_JEV_ROUTE_CONFIG,
|
|
93
|
+
timeoutMs: 5_000,
|
|
94
|
+
payloadBytes: 16 * 1024,
|
|
95
|
+
};
|
|
96
|
+
|
|
86
97
|
export const DEFAULT_JEV_ADVISORY_CONFIG: JevAdvisoryConfig = {
|
|
87
98
|
provider: "tokenin",
|
|
88
99
|
model: "jev-1.13",
|
|
@@ -90,6 +101,7 @@ export const DEFAULT_JEV_ADVISORY_CONFIG: JevAdvisoryConfig = {
|
|
|
90
101
|
memory: { ...DEFAULT_JEV_ROUTE_CONFIG },
|
|
91
102
|
recommendations: { ...DEFAULT_JEV_ROUTE_CONFIG },
|
|
92
103
|
ask: { ...DEFAULT_JEV_ASK_ROUTE_CONFIG },
|
|
104
|
+
subagent: { ...DEFAULT_JEV_SUBAGENT_ROUTE_CONFIG },
|
|
93
105
|
},
|
|
94
106
|
};
|
|
95
107
|
|
|
@@ -153,6 +165,7 @@ export function readJevAdvisoryConfig(settingsPath: string): JevAdvisoryConfig {
|
|
|
153
165
|
memory: routeOr(routes.memory),
|
|
154
166
|
recommendations: routeOr(routes.recommendations),
|
|
155
167
|
ask: routeOr(routes.ask, DEFAULT_JEV_ASK_ROUTE_CONFIG),
|
|
168
|
+
subagent: routeOr(routes.subagent, DEFAULT_JEV_SUBAGENT_ROUTE_CONFIG),
|
|
156
169
|
},
|
|
157
170
|
};
|
|
158
171
|
}
|
|
@@ -19,6 +19,8 @@ const state = vi.hoisted(() => ({
|
|
|
19
19
|
|
|
20
20
|
vi.mock("@selesai/code", () => ({
|
|
21
21
|
getSettingsPath: () => state.settingsPath,
|
|
22
|
+
// jev_find runs the real ripgrep on PATH against the temp repo.
|
|
23
|
+
ensureTool: async () => "rg",
|
|
22
24
|
// The same local shell backend the bash tool uses, scripted per command.
|
|
23
25
|
createLocalBashOperations: () => ({
|
|
24
26
|
exec: async (command: string, _cwd: string, options: { onData: (data: Buffer) => void }) => {
|
|
@@ -30,8 +32,8 @@ vi.mock("@selesai/code", () => ({
|
|
|
30
32
|
}),
|
|
31
33
|
}));
|
|
32
34
|
|
|
33
|
-
import jevAskToolExtension, { askPath, MAX_ASK_QUESTIONS } from "./jev-ask-tool.ts";
|
|
34
|
-
import { JEV_ROUTING_EVENT } from "./jev/decisions.ts";
|
|
35
|
+
import jevAskToolExtension, { askPath, FIND_BATCH, fitFindPayload, MAX_ASK_QUESTIONS } from "./jev-ask-tool.ts";
|
|
36
|
+
import { JEV_ROUTING_EVENT, serializeJevRequest } from "./jev/decisions.ts";
|
|
35
37
|
import { jevResponse, providerTemplate } from "./jev/test-support.ts";
|
|
36
38
|
|
|
37
39
|
const FILE_SENTINEL = "FILE_BODY_MUST_RETURN_TO_JE ONLY";
|
|
@@ -89,6 +91,9 @@ interface AskHarness {
|
|
|
89
91
|
ask(params: Record<string, unknown>, signal?: AbortSignal): Promise<{ text: string; details: Record<string, unknown> }>;
|
|
90
92
|
/** The decision request Jev was sent, parsed. */
|
|
91
93
|
sent(): Record<string, unknown> | undefined;
|
|
94
|
+
/** Every tool the extension registered, by name. */
|
|
95
|
+
tools: Record<string, ToolDefinition>;
|
|
96
|
+
ctx: unknown;
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
function harness(
|
|
@@ -114,10 +119,10 @@ function harness(
|
|
|
114
119
|
const hooks: Record<string, (event: unknown, ctx: unknown) => unknown> = {};
|
|
115
120
|
let credential = options.credential !== false;
|
|
116
121
|
const complete = vi.fn(async () => jevResponse(answerText({})));
|
|
117
|
-
|
|
122
|
+
const tools: Record<string, ToolDefinition> = {};
|
|
118
123
|
jevAskToolExtension({
|
|
119
124
|
registerTool: (definition: ToolDefinition) => {
|
|
120
|
-
|
|
125
|
+
tools[definition.name] = definition;
|
|
121
126
|
},
|
|
122
127
|
on: (event: string, handler: (event: unknown, ctx: unknown) => unknown) => {
|
|
123
128
|
hooks[event] = handler;
|
|
@@ -143,9 +148,11 @@ function harness(
|
|
|
143
148
|
complete,
|
|
144
149
|
},
|
|
145
150
|
};
|
|
146
|
-
const tool =
|
|
151
|
+
const tool = tools.ask_jev as ToolDefinition;
|
|
147
152
|
return {
|
|
148
153
|
tool,
|
|
154
|
+
tools,
|
|
155
|
+
ctx,
|
|
149
156
|
complete,
|
|
150
157
|
telemetry,
|
|
151
158
|
notices,
|
|
@@ -412,6 +419,64 @@ describe("missing material", () => {
|
|
|
412
419
|
});
|
|
413
420
|
});
|
|
414
421
|
|
|
422
|
+
describe("jev_find", () => {
|
|
423
|
+
const find = async (session: AskHarness, params: Record<string, unknown>) => {
|
|
424
|
+
const result = await session.tools.jev_find.execute("call-f", params, undefined, undefined, session.ctx);
|
|
425
|
+
return { text: result.content[0]?.text ?? "", details: result.details };
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
beforeAll(() => {
|
|
429
|
+
writeFileSync(join(repo, "src", "other.ts"), "// mentions round only in passing\nexport const x = 1;\n", "utf-8");
|
|
430
|
+
writeFileSync(join(repo, ".env"), "round=SECRET_ENV_VALUE\n", "utf-8");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("ranks ripgrep's candidates by Jev relevance and returns pointers, never contents", async () => {
|
|
434
|
+
const session = harness();
|
|
435
|
+
// Jev: the file whose question names round.ts is relevant, the rest are not.
|
|
436
|
+
session.complete.mockImplementation(async (_model: unknown, request: { messages: Array<{ content: string }> }) => {
|
|
437
|
+
const sent = JSON.parse(request.messages[0].content) as { questions: Record<string, { instructions: { question: string } }> };
|
|
438
|
+
const answers = Object.fromEntries(
|
|
439
|
+
Object.entries(sent.questions).map(([name, q]) => [name, { noul: q.instructions.question.includes("round.ts") ? 0.93 : 0.08 }]),
|
|
440
|
+
);
|
|
441
|
+
return jevResponse(answerText(answers));
|
|
442
|
+
});
|
|
443
|
+
const result = await find(session, { question: "where is rounding implemented", pattern: "round", ignoreCase: true });
|
|
444
|
+
|
|
445
|
+
expect(result.text).toMatch(/judged 2 of 2 candidate/);
|
|
446
|
+
expect(result.text).toContain("- src/round.ts:1 (0.93)");
|
|
447
|
+
expect(result.text).not.toContain("other.ts");
|
|
448
|
+
expect(result.text).not.toContain(FILE_SENTINEL);
|
|
449
|
+
const sent = session.sent() as { state: { files: Record<string, string> } };
|
|
450
|
+
expect(Object.keys(sent.state.files).sort()).toEqual(["src/other.ts", "src/round.ts"]);
|
|
451
|
+
expect(sent.state.files["src/round.ts"]).toContain(FILE_SENTINEL);
|
|
452
|
+
expect(JSON.stringify(sent)).not.toContain("SECRET_ENV_VALUE");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("lists a directory by glob and still returns candidates when Jev is unreachable", async () => {
|
|
456
|
+
const session = harness({ credential: false });
|
|
457
|
+
const result = await find(session, { question: "rounding", path: "src", glob: "*.ts" });
|
|
458
|
+
|
|
459
|
+
expect(session.complete).not.toHaveBeenCalled();
|
|
460
|
+
expect(result.text).toMatch(/Jev did not judge \(no-credential\); 2 candidate/);
|
|
461
|
+
// The question word in the path orders the unjudged listing.
|
|
462
|
+
expect(result.text.split("\n")[1]).toBe("- src/round.ts");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("shrinks escape-heavy snippets until a full batch fits the request budget", () => {
|
|
466
|
+
// Quotes and newlines double under JSON escaping, which is what overflowed live.
|
|
467
|
+
const snippet = '"\n\t\\'.repeat(1_000);
|
|
468
|
+
const batch = Array.from({ length: FIND_BATCH }, (_, i) => ({ path: `src/f${i}.ts`, lines: [], snippet }));
|
|
469
|
+
const payload = fitFindPayload("q", batch, 32 * 1024);
|
|
470
|
+
expect(serializeJevRequest(payload, 32 * 1024)).toBeDefined();
|
|
471
|
+
expect(Object.keys(payload.questions as object)).toHaveLength(FIND_BATCH);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("refuses a search root outside the working directory", async () => {
|
|
475
|
+
const result = await find(harness(), { question: "x", path: ".." });
|
|
476
|
+
expect(result.text).toContain("outside the working directory");
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
415
480
|
describe("askPath symlinks", () => {
|
|
416
481
|
it("refuses links that escape the working directory or point at a secret", () => {
|
|
417
482
|
const root = mkdtempSync(join(tmpdir(), "ask-path-"));
|