@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 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 { VERSION } from "../config.js";
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(VERSION),
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 it is never something the agent must discover.
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
- let registered: ToolDefinition | undefined;
122
+ const tools: Record<string, ToolDefinition> = {};
118
123
  jevAskToolExtension({
119
124
  registerTool: (definition: ToolDefinition) => {
120
- registered = definition;
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 = registered as ToolDefinition;
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-"));