@selesai/code 0.13.24 → 0.13.25

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,14 @@
2
2
 
3
3
  All notable changes to `@selesai/code` will be documented in this file.
4
4
 
5
+ ## [0.13.25] - 2026-09-14
6
+
7
+ ### Added
8
+ - **Graft deep-build model override.** Set `graft.deepModel` to pin a model on the active provider for Graft deep builds.
9
+
10
+ ### Changed
11
+ - **Graft deep-build fallback.** When the active model returns no usable Graft summaries, the deep build retries up to two other models scoped to that provider before reporting the deep-build failure.
12
+
5
13
  ## [0.13.24] - 2026-09-13
6
14
 
7
15
  ### Added
@@ -387,7 +387,7 @@ describe("mutation-capable commands", () => {
387
387
  const [title, message] = ctx.ui.confirm.mock.calls[0] as [string, string];
388
388
  expect(title).toContain("provider-backed");
389
389
  expect(message).toContain("leaves this machine");
390
- expect(message).toContain("credential is passed only to this Graft child process");
390
+ expect(message).toContain("Credentials are passed only to this Graft child process");
391
391
  expect(harness.runBuild).toHaveBeenCalledWith(true, ctx);
392
392
  });
393
393
 
@@ -330,7 +330,7 @@ export function registerGraftCommands(pi: ExtensionAPI, runtime: GraftCommandRun
330
330
  ? "Set up Graft for this repository?"
331
331
  : "Build the Graft graph?",
332
332
  deep
333
- ? `This runs \`graft build --deep\` in ${repo}.\n\nThe structural graph stays local and deterministic. The deep pass summarizes each changed file and extracts per-symbol cruxes using Selesai's active compatible model, so source-derived content leaves this machine. Its credential is passed only to this Graft child process.\n\nFiles it can write:\n${buildEffects(true)}`
333
+ ? `This runs \`graft build --deep\` in ${repo}.\n\nThe structural graph stays local and deterministic. The deep pass summarizes each changed file and extracts per-symbol cruxes using Selesai's active compatible model, falling back to another model you scoped on the same provider when that model cannot produce Graft summaries. Source-derived content leaves this machine either way. Credentials are passed only to this Graft child process.\n\nFiles it can write:\n${buildEffects(true)}`
334
334
  : `This runs \`graft build\` in ${repo} and can write:\n\n${buildEffects(false)}`,
335
335
  manualCommand,
336
336
  );
@@ -183,32 +183,94 @@ const GRAFT_PROVIDER_FOR_SELESAI_PROVIDER: Record<string, "openai" | "anthropic"
183
183
  tokenin: "litellm",
184
184
  };
185
185
 
186
- /** Resolve the active Selesai model into the isolated environment Graft needs. */
187
- async function activeModelGraftEnvironment(ctx: ExtensionContext): Promise<NodeJS.ProcessEnv | undefined> {
186
+ /**
187
+ * The deep tier either has a plan, or a human-readable reason it does not.
188
+ * Automatic builds fall back to the structural tier on `ok: false`; the
189
+ * explicit `/graft deep` command reports the reason instead.
190
+ */
191
+ type DeepBuildPlan =
192
+ | { ok: true; env: NodeJS.ProcessEnv | undefined; models: readonly string[] }
193
+ | { ok: false; reason: string };
194
+
195
+ /** Graft's per-symbol summary pass is model-sensitive; this is its failure signature. */
196
+ const UNUSABLE_SUMMARIES = /no usable symbol summaries|empty-parsed/i;
197
+
198
+ /**
199
+ * Deep-build candidates in order: an explicit `graft.deepModel` pin, the active
200
+ * model, then the models this session scoped on the same provider. No new
201
+ * setting is needed — models the user already chose are tried before giving up
202
+ * on the deep tier.
203
+ */
204
+ function deepBuildModels(ctx: ExtensionContext, settings: { deepModel?: string }): readonly string[] {
205
+ const active = ctx.model;
206
+ const scoped = (ctx.scopedModels ?? [])
207
+ .map((entry) => entry.model)
208
+ .filter((candidate) => candidate.provider === active?.provider)
209
+ .map((candidate) => candidate.id);
210
+ const ordered = [settings.deepModel, active?.id, ...scoped].filter((id): id is string => Boolean(id));
211
+ return [...new Set(ordered)].slice(0, 3);
212
+ }
213
+
214
+ /**
215
+ * Resolve the active Selesai model into the isolated environment Graft needs.
216
+ *
217
+ * Credentials are read from Selesai's own auth storage — TokenIn keys live in
218
+ * `~/.selesai/agent`, not in the shell environment — and passed only to the
219
+ * Graft child process.
220
+ */
221
+ async function resolveDeepBuildPlan(
222
+ ctx: ExtensionContext,
223
+ settings: { deepModel?: string } = {},
224
+ ): Promise<DeepBuildPlan> {
225
+ const models = deepBuildModels(ctx, settings);
226
+ // A user who already configured Graft's own provider environment outranks us.
227
+ if (process.env.GRAFT_PROVIDER && process.env.GRAFT_API_KEY) return { ok: true, env: undefined, models };
228
+
188
229
  const model = ctx.model;
189
- const provider = model ? GRAFT_PROVIDER_FOR_SELESAI_PROVIDER[model.provider] : undefined;
190
- if (!provider) return undefined;
230
+ if (!model) return { ok: false, reason: "no active model is selected" };
231
+ const provider = GRAFT_PROVIDER_FOR_SELESAI_PROVIDER[model.provider];
232
+ if (!provider) {
233
+ return {
234
+ ok: false,
235
+ reason: `provider "${model.provider}" has no Graft wire format (use TokenIn, LiteLLM, OpenAI, Anthropic, or OrcaRouter)`,
236
+ };
237
+ }
191
238
 
192
- let auth: Awaited<ReturnType<typeof ctx.modelRegistry.getProviderAuth>>;
239
+ let apiKey: string | undefined;
240
+ let authBaseUrl: string | undefined;
193
241
  try {
194
- auth = await ctx.modelRegistry.getProviderAuth(model.provider);
242
+ const auth = await ctx.modelRegistry.getProviderAuth(model.provider);
243
+ apiKey = auth?.auth.apiKey;
244
+ authBaseUrl = auth?.auth.baseUrl;
195
245
  } catch {
196
- return undefined;
246
+ // Fall through to the missing-credential reason below.
247
+ }
248
+ if (!apiKey) return { ok: false, reason: `no credential is stored for "${model.provider}"` };
249
+
250
+ // The composed model carries the endpoint; auth only sometimes does.
251
+ const baseUrl = authBaseUrl || model.baseUrl || ctx.modelRegistry.getProvider(model.provider)?.baseUrl;
252
+ if (provider === "litellm" && !baseUrl) {
253
+ return { ok: false, reason: `"${model.provider}" has no base URL to send Graft to` };
197
254
  }
198
- const apiKey = auth?.auth.apiKey;
199
- const baseUrl = auth?.auth.baseUrl;
200
- if (!apiKey || (provider === "litellm" && !baseUrl)) return undefined;
201
255
 
202
- const { GRAFT_PROVIDER: _provider, GRAFT_MODEL: _model, GRAFT_API_KEY: _apiKey, GRAFT_BASE_URL: _baseUrl, ...env } = process.env;
256
+ const { GRAFT_PROVIDER: _p, GRAFT_MODEL: _m, GRAFT_API_KEY: _k, GRAFT_BASE_URL: _u, ...env } = process.env;
203
257
  return {
204
- ...env,
205
- GRAFT_PROVIDER: provider,
206
- GRAFT_MODEL: model.id,
207
- GRAFT_API_KEY: apiKey,
208
- ...(baseUrl ? { GRAFT_BASE_URL: baseUrl } : {}),
258
+ ok: true,
259
+ models,
260
+ env: {
261
+ ...env,
262
+ GRAFT_PROVIDER: provider,
263
+ GRAFT_API_KEY: apiKey,
264
+ ...(baseUrl ? { GRAFT_BASE_URL: baseUrl } : {}),
265
+ },
209
266
  };
210
267
  }
211
268
 
269
+ /** Graft's environment for one candidate model; undefined keeps the user's own GRAFT_* setup. */
270
+ function deepEnvFor(plan: Extract<DeepBuildPlan, { ok: true }>, modelId: string): NodeJS.ProcessEnv | undefined {
271
+ return plan.env ? { ...plan.env, GRAFT_MODEL: modelId } : undefined;
272
+ }
273
+
212
274
  export default function graftExtension(pi: ExtensionAPI): void {
213
275
  registerBuiltinAgentAugmentation(pi);
214
276
  const exec: ExecLike = (command, args, options) => pi.exec(command, args, options);
@@ -277,6 +339,48 @@ export default function graftExtension(pi: ExtensionAPI): void {
277
339
  }
278
340
  };
279
341
 
342
+ /**
343
+ * Build the graph: deep with a compatible active model, structural otherwise.
344
+ *
345
+ * A model that cannot produce Graft-parsable summaries is not fatal — the
346
+ * next candidate the session already offers is tried before downgrading.
347
+ */
348
+ const buildGraph = async (
349
+ ctx: ExtensionContext,
350
+ activeSession: GraftSession,
351
+ phase: "build" | "refresh" = "build",
352
+ ): Promise<GraftRun> => {
353
+ const repoRoot = activeSession.repoRoot!;
354
+ // A refresh has already announced its own "syncing" phase at the caller.
355
+ const started = (deep: boolean): void => {
356
+ if (phase === "refresh") return;
357
+ if (session === activeSession && !activeSession.disposed) applyEvent(ctx, { type: "build-started", deep });
358
+ };
359
+ const structural = async (): Promise<GraftRun> => {
360
+ started(false);
361
+ return runGraft(exec, repoRoot, { kind: "build", deep: false });
362
+ };
363
+
364
+ const plan = await resolveDeepBuildPlan(ctx, activeSession.settings);
365
+ if (!plan.ok) return structural();
366
+
367
+ // The user's own GRAFT_* environment names the model; never second-guess it.
368
+ const candidates = plan.env ? plan.models : plan.models.slice(0, 1);
369
+ let failed: GraftRun | undefined;
370
+ for (const [index, model] of candidates.entries()) {
371
+ started(true);
372
+ const run = await runGraft(exec, repoRoot, { kind: "build", deep: true }, { env: deepEnvFor(plan, model) });
373
+ if (run.code === 0 || !UNUSABLE_SUMMARIES.test(`${run.stderr}\n${run.stdout}`)) return run;
374
+ failed = run;
375
+ if (index === 0 && candidates.length > 1 && ctx.hasUI) {
376
+ ctx.ui.notify(`Graft cannot summarize with ${model}; retrying the deep pass with another scoped model.`, "info");
377
+ }
378
+ }
379
+ // Nothing left to try: report the last run rather than pretending the
380
+ // structural tier explained the failure.
381
+ return failed ?? (await structural());
382
+ };
383
+
280
384
  /**
281
385
  * Start the graph once. Compatible active Selesai models add the deep tier;
282
386
  * otherwise the free local structural tier remains usable.
@@ -290,11 +394,7 @@ export default function graftExtension(pi: ExtensionAPI): void {
290
394
  activeSession.state.name === "failed"
291
395
  ) return undefined;
292
396
 
293
- const pending = activeModelGraftEnvironment(ctx).then(async (env) => {
294
- const deep = env !== undefined;
295
- if (session === activeSession && !activeSession.disposed) applyEvent(ctx, { type: "build-started", deep });
296
- return runGraft(exec, activeSession.repoRoot!, { kind: "build", deep }, { env });
297
- });
397
+ const pending = buildGraph(ctx, activeSession);
298
398
  structuralBuildPromise = pending;
299
399
  structuralBuildSession = activeSession;
300
400
  void pending.then(
@@ -544,11 +644,9 @@ export default function graftExtension(pi: ExtensionAPI): void {
544
644
 
545
645
  void (async () => {
546
646
  try {
547
- const env = await activeModelGraftEnvironment(ctx);
548
- const deep = env !== undefined;
549
- const run = await runGraft(exec, session.repoRoot!, { kind: "build", deep }, { env });
647
+ const run = await buildGraph(ctx, session, "refresh");
550
648
  if (session.disposed) return;
551
- recordBuild(ctx, run, deep);
649
+ recordBuild(ctx, run, run.op.kind === "build" && run.op.deep);
552
650
  } catch (error) {
553
651
  if (session.disposed) return;
554
652
  applyEvent(ctx, {
@@ -584,24 +682,27 @@ export default function graftExtension(pi: ExtensionAPI): void {
584
682
  if (!deep) {
585
683
  const pending = autoBuild(ctx, session);
586
684
  if (pending) return pending;
685
+ applyEvent(ctx, { type: "build-started", deep: false });
686
+ const structural = await runGraft(exec, session.repoRoot!, { kind: "build", deep: false });
687
+ recordBuild(ctx, structural, false);
688
+ return structural;
587
689
  }
588
- const env = deep ? await activeModelGraftEnvironment(ctx) : undefined;
589
- if (deep && !env) {
690
+ const plan = await resolveDeepBuildPlan(ctx, session.settings);
691
+ if (!plan.ok) {
590
692
  return {
591
- op: { kind: "build", deep },
693
+ op: { kind: "build", deep: true },
592
694
  argv: ["graft", "build", "--deep"],
593
695
  cwd: session.repoRoot!,
594
696
  code: 1,
595
697
  stdout: "",
596
- stderr: "The active Selesai model cannot be used for Graft deep builds.",
698
+ stderr: `No deep build is possible for the active model: ${plan.reason}.`,
597
699
  killed: false,
598
700
  cancelled: false,
599
701
  timedOut: false,
600
702
  };
601
703
  }
602
- applyEvent(ctx, { type: "build-started", deep });
603
- const run = await runGraft(exec, session.repoRoot!, { kind: "build", deep }, { env });
604
- recordBuild(ctx, run, deep);
704
+ const run = await buildGraph(ctx, session);
705
+ recordBuild(ctx, run, run.op.kind === "build" && run.op.deep);
605
706
  return run;
606
707
  },
607
708
  runRefresh: async (ctx) => {
@@ -609,10 +710,8 @@ export default function graftExtension(pi: ExtensionAPI): void {
609
710
  await ensureReady(ctx, { kind: "build", deep: false });
610
711
  session.lastRefreshAt = Date.now();
611
712
  applyEvent(ctx, { type: "refresh-started" });
612
- const env = await activeModelGraftEnvironment(ctx);
613
- const deep = env !== undefined;
614
- const run = await runGraft(exec, session.repoRoot!, { kind: "build", deep }, { env });
615
- recordBuild(ctx, run, deep);
713
+ const run = await buildGraph(ctx, session, "refresh");
714
+ recordBuild(ctx, run, run.op.kind === "build" && run.op.deep);
616
715
  return run;
617
716
  },
618
717
  providerEnvNames: () => PROVIDER_ENV_NAMES.filter((name) => Boolean(process.env[name])),
@@ -304,11 +304,11 @@ describe("proactive refresh", () => {
304
304
  });
305
305
  graftExtension(harness.pi as never);
306
306
  const ctx = makeCtx({ cwd: bare });
307
+ // Mirrors the real TokenIn shape: the key lives in ~/.selesai auth storage and
308
+ // the endpoint comes from the composed model, not from the auth resolution.
307
309
  Object.assign(ctx, {
308
- model: { provider: "tokenin", id: "deepseek-v4-flash" },
309
- modelRegistry: {
310
- getProviderAuth: vi.fn(async () => ({ auth: { apiKey: "test-token", baseUrl: "https://lite.andlet.me/v1" } })),
311
- },
310
+ model: { provider: "tokenin", id: "deepseek-v4.1-flash", baseUrl: "https://lite.andlet.me/v1" },
311
+ modelRegistry: { getProviderAuth: vi.fn(async () => ({ auth: { apiKey: "test-token" } })) },
312
312
  });
313
313
  await (handlerFor(harness, "session_start") as (e: unknown, c: unknown) => Promise<void>)(
314
314
  { type: "session_start", reason: "startup" },
@@ -317,7 +317,80 @@ describe("proactive refresh", () => {
317
317
  await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ● deep v0.18.0"));
318
318
  const build = harness.exec.mock.calls.find((call) => (call[1] as string[]).includes("build"))!;
319
319
  expect(build[1]).toEqual(["build", "--deep"]);
320
- expect(build[2]).toMatchObject({ env: { GRAFT_PROVIDER: "litellm", GRAFT_MODEL: "deepseek-v4-flash" } });
320
+ expect(build[2]).toMatchObject({
321
+ env: {
322
+ GRAFT_PROVIDER: "litellm",
323
+ GRAFT_MODEL: "deepseek-v4.1-flash",
324
+ GRAFT_API_KEY: "test-token",
325
+ GRAFT_BASE_URL: "https://lite.andlet.me/v1",
326
+ },
327
+ });
328
+ });
329
+
330
+ it("retries the deep pass with another scoped model when the active one cannot summarize", async () => {
331
+ process.env.PI_CODING_AGENT_DIR = agentDir;
332
+ const bare = join(root, "retry-repo");
333
+ mkdirSync(join(bare, ".selesai"), { recursive: true });
334
+ writeFileSync(join(bare, ".selesai", "settings.json"), JSON.stringify({ graft: {} }), "utf-8");
335
+ const models: string[] = [];
336
+ const harness = makePi(async (command: string, args: string[], options?: unknown) => {
337
+ if (command === "git") return { ...OK, stdout: `${bare}\n` };
338
+ if (args.includes("--version")) return { ...OK, stdout: "0.18.0\n" };
339
+ if (args.includes("build")) {
340
+ const model = (options as { env?: Record<string, string> }).env?.GRAFT_MODEL ?? "";
341
+ models.push(model);
342
+ if (model === "deepseek-v4.1-flash") {
343
+ return { ...OK, code: 1, stderr: "calc.ts: model returned no usable symbol summaries [empty-parsed]" };
344
+ }
345
+ mkdirSync(join(bare, "graft", ".graph"), { recursive: true });
346
+ writeFileSync(join(bare, "graft", ".graph", "wiring.json"), "{}", "utf-8");
347
+ writeFileSync(join(bare, "graft", "concept.md"), "# Concept", "utf-8");
348
+ }
349
+ return { ...OK, stdout: ASK_JSON };
350
+ });
351
+ graftExtension(harness.pi as never);
352
+ const ctx = makeCtx({ cwd: bare });
353
+ Object.assign(ctx, {
354
+ model: { provider: "tokenin", id: "deepseek-v4.1-flash", baseUrl: "https://lite.andlet.me/v1" },
355
+ modelRegistry: { getProviderAuth: vi.fn(async () => ({ auth: { apiKey: "test-token" } })) },
356
+ scopedModels: [{ model: { provider: "tokenin", id: "glm-5.3-flash" } }],
357
+ });
358
+ await (handlerFor(harness, "session_start") as (e: unknown, c: unknown) => Promise<void>)(
359
+ { type: "session_start", reason: "startup" },
360
+ ctx,
361
+ );
362
+ await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ● deep v0.18.0"));
363
+ expect(models).toEqual(["deepseek-v4.1-flash", "glm-5.3-flash"]);
364
+ expect(notified(ctx)).toContain("retrying the deep pass");
365
+ });
366
+
367
+ it("falls back to the structural build when the active model cannot go deep", async () => {
368
+ process.env.PI_CODING_AGENT_DIR = agentDir;
369
+ const bare = join(root, "fallback-repo");
370
+ mkdirSync(join(bare, ".selesai"), { recursive: true });
371
+ writeFileSync(join(bare, ".selesai", "settings.json"), JSON.stringify({ graft: {} }), "utf-8");
372
+ const harness = makePi(async (command: string, args: string[]) => {
373
+ if (command === "git") return { ...OK, stdout: `${bare}\n` };
374
+ if (args.includes("--version")) return { ...OK, stdout: "0.18.0\n" };
375
+ if (args.includes("build")) {
376
+ mkdirSync(join(bare, "graft", ".graph"), { recursive: true });
377
+ writeFileSync(join(bare, "graft", ".graph", "wiring.json"), "{}", "utf-8");
378
+ }
379
+ return { ...OK, stdout: ASK_JSON };
380
+ });
381
+ graftExtension(harness.pi as never);
382
+ const ctx = makeCtx({ cwd: bare });
383
+ Object.assign(ctx, {
384
+ model: { provider: "google", id: "gemini-3-pro" },
385
+ modelRegistry: { getProviderAuth: vi.fn(async () => ({ auth: { apiKey: "test-key" } })) },
386
+ });
387
+ await (handlerFor(harness, "session_start") as (e: unknown, c: unknown) => Promise<void>)(
388
+ { type: "session_start", reason: "startup" },
389
+ ctx,
390
+ );
391
+ await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ● structural v0.18.0"));
392
+ const build = harness.exec.mock.calls.find((call) => (call[1] as string[]).includes("build"))!;
393
+ expect(build[1]).toEqual(["build"]);
321
394
  });
322
395
 
323
396
  it("re-indexes after edits, shows the sync phase, and returns to fresh", async () => {
@@ -240,12 +240,18 @@ describe("readGraftSettings", () => {
240
240
  maxInjectionBytes: -1,
241
241
  maxResultBytes: 0,
242
242
  refreshDebounceSeconds: "60",
243
+ deepModel: " ",
243
244
  in: " ",
244
245
  },
245
246
  });
246
247
  expect(read(false)).toEqual({});
247
248
  });
248
249
 
250
+ it("keeps a deep-build model override for the active provider", () => {
251
+ write(join(agentDir, "settings.json"), { graft: { deepModel: " glm-5.3-flash " } });
252
+ expect(read(false)).toEqual({ deepModel: "glm-5.3-flash" });
253
+ });
254
+
249
255
  it("keeps a valid enabled flag and telemetry preference", () => {
250
256
  write(join(agentDir, "settings.json"), { graft: { enabled: false, telemetry: "inherit" } });
251
257
  expect(read(false)).toEqual({ enabled: false, telemetry: "inherit" });
@@ -57,6 +57,11 @@ export interface GraftSettings {
57
57
  in?: string;
58
58
  /** Seconds a mutation stays "changed" before a proactive refresh is attempted. */
59
59
  refreshDebounceSeconds?: number;
60
+ /**
61
+ * Model id for deep builds, on the active provider. Defaults to the active
62
+ * model; set it when that model cannot produce Graft-parsable summaries.
63
+ */
64
+ deepModel?: string;
60
65
  }
61
66
 
62
67
  export const SETTINGS_KEY = "graft";
@@ -97,6 +102,7 @@ function normalizeSettings(raw: unknown): GraftSettings {
97
102
  if (typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0) settings[key] = candidate;
98
103
  }
99
104
  if (typeof value.in === "string" && value.in.trim()) settings.in = value.in.trim();
105
+ if (typeof value.deepModel === "string" && value.deepModel.trim()) settings.deepModel = value.deepModel.trim();
100
106
  return settings;
101
107
  }
102
108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selesai/code",
3
- "version": "0.13.24",
3
+ "version": "0.13.25",
4
4
  "description": "Maintained, extension-first Pi coding agent with built-in workflows, subagents, web research, questions, skills, and an enhanced terminal UI.",
5
5
  "type": "module",
6
6
  "repository": {