@selesai/code 0.13.35 → 0.13.38

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,27 @@
2
2
 
3
3
  All notable changes to `@selesai/code` will be documented in this file.
4
4
 
5
+ ## [0.13.38] - 2026-10-01
6
+
7
+ ### Changed
8
+ - **`jev_find` returns source evidence, not just file pointers.** Large trees are narrowed through directory, file, and source-unit judging; results include ranked files, reading leads, and accepted source verbatim with original line numbers, capped at 12 Jev requests and 16 KiB of source per call. JavaScript and TypeScript are split into declarations when the parser is available; other files and installs without it use bounded text chunks.
9
+
10
+ ### Fixed
11
+ - **Jev Find remains useful when Jev cannot judge.** It returns ripgrep-ranked candidates with local keyword-window source, and provider diagnostics expose only a failure category and optional HTTP status, never raw provider error text.
12
+
13
+ ## [0.13.37] - 2026-09-30
14
+
15
+ ### Changed
16
+ - **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.
17
+
18
+ ## [0.13.36] - 2026-09-29
19
+
20
+ ### Fixed
21
+ - **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.
22
+
23
+ ### Changed
24
+ - **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.
25
+
5
26
  ## [0.13.35] - 2026-09-29
6
27
 
7
28
  ### 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
  });
@@ -17,6 +17,7 @@ const state = vi.hoisted(() => ({ settingsPath: "" }));
17
17
  // layer owns the non-streaming decisions request), so a suite stubs that.
18
18
  import {
19
19
  askJev,
20
+ askJevAnswers,
20
21
  buildConversation,
21
22
  buildJevPayload,
22
23
  confidenceBucket,
@@ -278,6 +279,23 @@ describe("askJev", () => {
278
279
  expect(await ask()).toMatchObject({ failure: "malformed" });
279
280
  });
280
281
 
282
+ it("reports safe transport diagnostics without returning raw provider errors", async () => {
283
+ const error = Object.assign(new Error("secret=must-not-escape"), { status: 503 });
284
+ completeMock.mockRejectedValue(error);
285
+
286
+ const result = await askJevAnswers(runtime() as never, CONNECTION, { payload: {}, maxBytes: 1_024 });
287
+ expect(result).toMatchObject({ failure: "transport", diagnostic: { kind: "provider-error", httpStatus: 503 } });
288
+ expect(JSON.stringify(result)).not.toContain("must-not-escape");
289
+ });
290
+
291
+ it("extracts only the HTTP status from TokenIn's error completion", async () => {
292
+ completeMock.mockResolvedValue(jevResponse("", { stopReason: "error", errorMessage: "HTTP 429: private response body" }));
293
+
294
+ const result = await askJevAnswers(runtime() as never, CONNECTION, { payload: {}, maxBytes: 1_024 });
295
+ expect(result).toMatchObject({ failure: "transport", diagnostic: { kind: "provider-error", httpStatus: 429 } });
296
+ expect(JSON.stringify(result)).not.toContain("private response body");
297
+ });
298
+
281
299
  it("abstains per question on an unlisted, low-confidence, or missing answer", async () => {
282
300
  completeMock.mockResolvedValue(jevResponse(jevAnswers({ repository_context: { choice: "maybe", confidence: 1 } })));
283
301
  expect(await ask()).toMatchObject({ choices: {}, failure: "unknown-choice" });
@@ -426,10 +426,41 @@ function responseText(response: { content: readonly { type: string; text?: strin
426
426
  }
427
427
 
428
428
  function failureReason(error: unknown): JevAbstainReason {
429
- if (isRecord(error) && (error.name === "TimeoutError" || error.name === "AbortError")) return "timeout";
429
+ try {
430
+ if (isRecord(error) && (error.name === "TimeoutError" || error.name === "AbortError")) return "timeout";
431
+ } catch {
432
+ // A hostile error object is still just a transport failure.
433
+ }
430
434
  return "transport";
431
435
  }
432
436
 
437
+ function failureDiagnostic(error: unknown, failure: JevAbstainReason): JevFailureDiagnostic {
438
+ if (failure === "timeout") return { kind: "timeout" };
439
+ try {
440
+ const response = isRecord(error) && isRecord(error.response) ? error.response : undefined;
441
+ const message =
442
+ typeof error === "string"
443
+ ? error
444
+ : error instanceof Error
445
+ ? error.message
446
+ : isRecord(error) && typeof error.errorMessage === "string"
447
+ ? error.errorMessage
448
+ : "";
449
+ const messageStatus = /^HTTP (\d{3}):/.exec(message)?.[1];
450
+ const httpStatus =
451
+ (isRecord(error) ? (error.status ?? error.statusCode ?? response?.status) : undefined) ??
452
+ (messageStatus ? Number(messageStatus) : undefined);
453
+ return {
454
+ kind: "provider-error",
455
+ ...(typeof httpStatus === "number" && Number.isInteger(httpStatus) && httpStatus >= 100 && httpStatus <= 599
456
+ ? { httpStatus }
457
+ : {}),
458
+ };
459
+ } catch {
460
+ return { kind: "provider-error" };
461
+ }
462
+ }
463
+
433
464
  interface JevAuth {
434
465
  ok: boolean;
435
466
  apiKey?: string;
@@ -494,10 +525,17 @@ export async function askJev(
494
525
  return { choices, rejected, elapsedMs: result.elapsedMs };
495
526
  }
496
527
 
528
+ /** Safe, content-free context for a failed Jev request. Never contains provider error text. */
529
+ export interface JevFailureDiagnostic {
530
+ kind: "timeout" | "provider-error" | "malformed-response";
531
+ httpStatus?: number;
532
+ }
533
+
497
534
  /** Jev's raw `answers` envelope, or why the request produced none. */
498
535
  export interface JevAnswers {
499
536
  answers?: Record<string, unknown>;
500
537
  failure?: JevAbstainReason;
538
+ diagnostic?: JevFailureDiagnostic;
501
539
  elapsedMs: number;
502
540
  }
503
541
 
@@ -510,7 +548,8 @@ export interface JevAnswers {
510
548
  * was asked. Nothing beyond the envelope is validated here, so a caller that
511
549
  * expects typed answers owns interpreting them. Every failure — no provider
512
550
  * template, no credential, an oversized request, a timeout, a rejected call, or
513
- * malformed JSON — is an absent decision, and this never throws.
551
+ * malformed JSON — is an absent decision, and this never throws. Failure diagnostics
552
+ * contain only a category and optional HTTP status, never provider error text.
514
553
  */
515
554
  export async function askJevAnswers(
516
555
  ctx: JevRuntime,
@@ -518,7 +557,11 @@ export async function askJevAnswers(
518
557
  request: { payload: unknown; maxBytes: number },
519
558
  ): Promise<JevAnswers> {
520
559
  const started = Date.now();
521
- const abstained = (failure: JevAbstainReason): JevAnswers => ({ failure, elapsedMs: Date.now() - started });
560
+ const abstained = (failure: JevAbstainReason, diagnostic?: JevFailureDiagnostic): JevAnswers => ({
561
+ failure,
562
+ ...(diagnostic ? { diagnostic } : {}),
563
+ elapsedMs: Date.now() - started,
564
+ });
522
565
 
523
566
  const access = await jevAccess(ctx, connection);
524
567
  if ("failure" in access) return abstained(access.failure);
@@ -539,16 +582,21 @@ export async function askJevAnswers(
539
582
  { apiKey: auth.apiKey, headers: auth.headers, maxTokens: JEV_MAX_TOKENS, signal },
540
583
  );
541
584
  } catch (error) {
542
- return abstained(failureReason(error));
585
+ const failure = failureReason(error);
586
+ return abstained(failure, failureDiagnostic(error, failure));
543
587
  }
544
588
  // A provider failure or a deadline arrives as a message with an error stop
545
589
  // reason rather than as a thrown error.
546
590
  if (completion.stopReason === "error" || completion.stopReason === "aborted") {
547
- return abstained(signal.aborted ? "timeout" : "transport");
591
+ const failure = signal.aborted ? "timeout" : "transport";
592
+ return abstained(
593
+ failure,
594
+ failure === "timeout" ? { kind: "timeout" } : failureDiagnostic(completion.errorMessage, failure),
595
+ );
548
596
  }
549
597
 
550
598
  const answers = parseAnswers(responseText(completion));
551
- if (!answers) return abstained("malformed");
599
+ if (!answers) return abstained("malformed", { kind: "malformed-response" });
552
600
  return { answers, elapsedMs: Date.now() - started };
553
601
  }
554
602