@selesai/code 0.13.23 → 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,25 @@
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
+
13
+ ## [0.13.24] - 2026-09-13
14
+
15
+ ### Added
16
+ - **Self-building Graft graph.** Graft now creates the initial graph itself instead of waiting for a manual build: the first session start, the first read-only tool call, and a completed CLI install each trigger one build for the repository. A compatible active Selesai model (TokenIn, LiteLLM, OpenAI, Anthropic, or OrcaRouter) adds the deep tier; otherwise the build stays local and structural.
17
+ - **Active-model deep builds.** Deep enrichment now runs on the active Selesai model instead of Graft's own provider configuration. The credential is resolved per build and passed only to the Graft child process, which never inherits it through the host environment.
18
+
19
+ ### Changed
20
+ - **Graft's default retrieval mode is `hybrid`.** Eligible coding turns start from a smaller orientation pack while precise Graft tools stay available; set `graft.mode: "push"` for the previous full-pack behavior.
21
+ - **Deep is the default build.** `/graft build` and `/graft setup` now run the provider-backed deep build, with the new `/graft build --structural` as the explicit local, offline escape hatch.
22
+ - **Graft doctor and recovery hints.** Provider reporting now describes active-model deep builds and the child-process credential boundary instead of asking for Graft's own provider environment variables.
23
+
5
24
  ## [0.13.23] - 2026-09-13
6
25
 
7
26
  ### Added
@@ -11,6 +11,8 @@ export interface ExecOptions {
11
11
  timeout?: number;
12
12
  /** Working directory */
13
13
  cwd?: string;
14
+ /** Environment for this child process (defaults to the host environment). */
15
+ env?: NodeJS.ProcessEnv;
14
16
  }
15
17
  /**
16
18
  * Result of executing a shell command.
package/dist/core/exec.js CHANGED
@@ -11,6 +11,7 @@ export async function execCommand(command, args, cwd, options) {
11
11
  return new Promise((resolve) => {
12
12
  const proc = spawn(command, args, {
13
13
  cwd,
14
+ env: options?.env,
14
15
  shell: false,
15
16
  stdio: ["ignore", "pipe", "pipe"],
16
17
  });
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
+ import { execCommand } from "../../core/exec.ts";
2
3
  import {
3
4
  applyTelemetryDefault,
4
5
  askReferences,
@@ -32,6 +33,19 @@ function execReturning(result: StubExecResult) {
32
33
  return { ...result };
33
34
  }
34
35
 
36
+ describe("execCommand environment", () => {
37
+ it("passes a supplied credential only to its child process", async () => {
38
+ const result = await execCommand(
39
+ process.execPath,
40
+ ["-e", "process.stdout.write(process.env.GRAFT_TEST_TOKEN ?? '')"],
41
+ process.cwd(),
42
+ { env: { ...process.env, GRAFT_TEST_TOKEN: "child-only" } },
43
+ );
44
+ expect(result).toMatchObject({ code: 0, stdout: "child-only" });
45
+ expect(process.env.GRAFT_TEST_TOKEN).toBeUndefined();
46
+ });
47
+ });
48
+
35
49
  describe("graftCommand", () => {
36
50
  it("invokes the executable directly off Windows", () => {
37
51
  const original = process.platform;
@@ -153,6 +167,15 @@ describe("probeGraft", () => {
153
167
  });
154
168
  });
155
169
 
170
+ describe("runGraft", () => {
171
+ it("forwards a private provider environment only to the Graft child", async () => {
172
+ const { pi } = makePi(async () => execReturning(OK));
173
+ const env = { ...process.env, GRAFT_PROVIDER: "litellm", GRAFT_MODEL: "deepseek-v4-flash", GRAFT_API_KEY: "secret" };
174
+ await runGraft(pi.exec as never, "/repo", { kind: "build", deep: true }, { env });
175
+ expect(pi.exec.mock.calls[0]![2]).toMatchObject({ env });
176
+ });
177
+ });
178
+
156
179
  describe("graftArgs", () => {
157
180
  it("maps every read-only operation to its documented CLI invocation", () => {
158
181
  const cases: [GraftOp, string[]][] = [
@@ -11,8 +11,8 @@
11
11
  * paths, regexes, symbols, and question text cannot become shell injection.
12
12
  * - Graft's upstream agent-wiring command (`init`) is never invoked. A missing
13
13
  * compatible CLI is installed once through npm; incompatible CLIs are left alone.
14
- * - Semantic tools are read-only. Graph builds are command operations gated
15
- * behind explicit consent; CLI provisioning is automatic.
14
+ * - Semantic tools are read-only. Automatic builds use the active compatible
15
+ * Selesai model for deep enrichment, otherwise stay structural and local.
16
16
  * - Extension-spawned commands default to telemetry opt-out (`DO_NOT_TRACK=1`).
17
17
  *
18
18
  * Verified against @nanonets/graft 0.18.0 (see {@link MIN_GRAFT_VERSION}).
@@ -144,8 +144,8 @@ export async function probeGraft(exec: ExecLike, cwd: string, signal?: AbortSign
144
144
  // ---------------------------------------------------------------------------
145
145
 
146
146
  /**
147
- * The read-only operations this adapter exposes, plus the two consent-gated
148
- * build operations. Every other Graft subcommand (including `init`, `viz`, and
147
+ * The read-only operations this adapter exposes, plus structural and deep
148
+ * builds. Every other Graft subcommand (including `init`, `viz`, and
149
149
  * `uninstall`) is deliberately unreachable from tool calls.
150
150
  */
151
151
  export type GraftOp =
@@ -286,7 +286,7 @@ export async function runGraft(
286
286
  exec: ExecLike,
287
287
  repoRoot: string,
288
288
  op: GraftOp,
289
- options: { signal?: AbortSignal; timeout?: number } = {},
289
+ options: { signal?: AbortSignal; timeout?: number; env?: NodeJS.ProcessEnv } = {},
290
290
  ): Promise<GraftRun> {
291
291
  const args = graftArgs(op);
292
292
  const { command, args: argv } = graftCommand(args);
@@ -297,6 +297,7 @@ export async function runGraft(
297
297
  cwd: repoRoot,
298
298
  timeout: options.timeout ?? timeoutFor(op),
299
299
  signal: options.signal,
300
+ env: options.env,
300
301
  });
301
302
  } catch (error) {
302
303
  return {
@@ -139,14 +139,14 @@ describe("formatDoctor", () => {
139
139
  expect(text).toContain("fresh-structural");
140
140
  expect(text).toContain("nothing is injected");
141
141
  expect(text).toContain("DO_NOT_TRACK=1 set by this extension");
142
- expect(text).toContain("deep builds would need Graft's own provider configuration");
142
+ expect(text).toContain("automatic deep builds use Selesai's active compatible model");
143
143
  expect(text).toContain("graft deep adds provider-backed summaries");
144
144
  });
145
145
 
146
146
  it("shows only which provider variables are set, never their values", () => {
147
147
  const text = formatDoctor({ ...base, providerEnv: ["GRAFT_PROVIDER", "GRAFT_MODEL"] });
148
148
  expect(text).toContain("GRAFT_PROVIDER, GRAFT_MODEL set");
149
- expect(text).toContain("values never read or copied");
149
+ expect(text).toContain("values never shown");
150
150
  });
151
151
 
152
152
  it("reports whether this session actually applied the telemetry default", () => {
@@ -205,8 +205,8 @@ describe("buildEffects", () => {
205
205
 
206
206
  it("states the provider boundary and the cache for a deep build", () => {
207
207
  const text = buildEffects(true);
208
- expect(text).toContain("GRAFT_PROVIDER");
209
- expect(text).toContain("Graft's own environment");
208
+ expect(text).toContain("Selesai's active compatible model");
209
+ expect(text).toContain("Graft child process");
210
210
  expect(text).toContain("caches those summaries under graft/");
211
211
  });
212
212
  });
@@ -289,7 +289,7 @@ describe("mutation-capable commands", () => {
289
289
  }
290
290
  });
291
291
 
292
- it("installs a missing CLI, rechecks it, then asks only before building", async () => {
292
+ it("installs a missing CLI, rechecks it, then asks before the default deep build", async () => {
293
293
  const harness = makeRuntime({ state: { name: "unavailable", detail: "ENOENT" }, built: false });
294
294
  const ctx = makeCtx();
295
295
  await invoke(harness.runtime, "setup", ctx);
@@ -297,8 +297,8 @@ describe("mutation-capable commands", () => {
297
297
  expect(harness.installCli).toHaveBeenCalledWith(ctx);
298
298
  expect(harness.recheck).toHaveBeenCalledTimes(3);
299
299
  expect(ctx.ui.confirm).toHaveBeenCalledTimes(1);
300
- expect(ctx.ui.confirm.mock.calls[0]![0]).toBe("Set up Graft for this repository?");
301
- expect(harness.runBuild).toHaveBeenCalledWith(false, ctx);
300
+ expect(ctx.ui.confirm.mock.calls[0]![0]).toBe("Run the provider-backed deep build?");
301
+ expect(harness.runBuild).toHaveBeenCalledWith(true, ctx);
302
302
  });
303
303
 
304
304
  it("does not build when installing the missing CLI fails", async () => {
@@ -345,14 +345,16 @@ describe("mutation-capable commands", () => {
345
345
  expect(message).toContain(".gitignore");
346
346
  expect(message).toContain("init");
347
347
  expect(message).toContain("never run");
348
- expect(harness.runBuild).toHaveBeenCalledWith(false, ctx);
348
+ expect(harness.runBuild).toHaveBeenCalledWith(true, ctx);
349
349
  expect(notified(ctx)).toContain("Done.");
350
350
  });
351
351
 
352
- it("does not run a deep build when the structural build was confirmed", async () => {
352
+ it("defaults build to deep, with an explicit structural escape hatch", async () => {
353
353
  const harness = makeRuntime();
354
354
  await invoke(harness.runtime, "build", makeCtx());
355
- expect(harness.runBuild).toHaveBeenCalledWith(false, expect.anything());
355
+ expect(harness.runBuild).toHaveBeenCalledWith(true, expect.anything());
356
+ await invoke(harness.runtime, "build --structural", makeCtx());
357
+ expect(harness.runBuild).toHaveBeenLastCalledWith(false, expect.anything());
356
358
  });
357
359
 
358
360
  it("gives `build --deep` the same provider consent as `/graft deep`", async () => {
@@ -371,10 +373,10 @@ describe("mutation-capable commands", () => {
371
373
  expect(harness.runBuild).toHaveBeenNthCalledWith(2, true, viaDeep);
372
374
  });
373
375
 
374
- it("treats setup as the structural build", async () => {
376
+ it("treats setup as the default deep build", async () => {
375
377
  const harness = makeRuntime();
376
378
  await invoke(harness.runtime, "setup", makeCtx());
377
- expect(harness.runBuild).toHaveBeenCalledWith(false, expect.anything());
379
+ expect(harness.runBuild).toHaveBeenCalledWith(true, expect.anything());
378
380
  });
379
381
 
380
382
  it("shows the provider boundary before a deep build and only sends source off-machine after consent", async () => {
@@ -385,7 +387,7 @@ describe("mutation-capable commands", () => {
385
387
  const [title, message] = ctx.ui.confirm.mock.calls[0] as [string, string];
386
388
  expect(title).toContain("provider-backed");
387
389
  expect(message).toContain("leaves this machine");
388
- expect(message).toContain("does not read, copy, or forward");
390
+ expect(message).toContain("Credentials are passed only to this Graft child process");
389
391
  expect(harness.runBuild).toHaveBeenCalledWith(true, ctx);
390
392
  });
391
393
 
@@ -472,7 +474,7 @@ describe("runtime contract", () => {
472
474
  const ctx = makeCtx();
473
475
  await invoke(harness.runtime, "status", ctx);
474
476
  expect(PROVIDER_ENV_NAMES).toContain("GRAFT_API_KEY");
475
- expect(DEEP_PROVIDER_HINT).toContain("never forwards Selesai credentials");
477
+ expect(DEEP_PROVIDER_HINT).toContain("active Selesai model");
476
478
  expect(DOCTOR_USAGE).toContain("mode [pull|push|hybrid]");
477
479
  });
478
480
  });
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * The `/graft` command surface: setup, build, deep, refresh, status, doctor, mode.
3
3
  *
4
- * Command handlers own two things the tools never touch: consent, and honest
5
- * reporting. A command that can write to the repository says exactly which
6
- * files it may create or change and asks first; with no dialog UI it refuses
7
- * and prints the exact manual command instead.
4
+ * Command handlers own reporting and the explicit deep-build confirmation.
5
+ * They say exactly which repository files may change; with no dialog UI a
6
+ * manual deep build refuses and prints the exact command instead.
8
7
  *
9
8
  * Nothing here ever invokes Graft's upstream agent-wiring command. Selesai's
10
9
  * setup installs a missing CLI automatically, explains local effects, and builds
@@ -61,7 +60,7 @@ export interface GraftCommandRuntime {
61
60
  graphPresence(): GraphPresence;
62
61
  /** Install the compatible CLI when probing found none. */
63
62
  installCli(ctx: ExtensionContext): Promise<GraftInstallRun>;
64
- /** Structural or deep build. Call only after consent. */
63
+ /** Structural or deep build. Deep calls only after consent. */
65
64
  runBuild(deep: boolean, ctx: ExtensionContext): Promise<GraftRun>;
66
65
  /** Proactive refresh of an existing graph; undefined when there is nothing to refresh. */
67
66
  runRefresh(ctx: ExtensionContext): Promise<GraftRun | undefined>;
@@ -75,8 +74,8 @@ export const DOCTOR_USAGE = `/graft <command>
75
74
 
76
75
  status current availability, graph state, and retrieval mode
77
76
  doctor full health report with a recovery step for each problem
78
- setup install the CLI if needed, then explain build effects and ask for consent
79
- build build graft/ for this repository (structural; no model, no key)
77
+ setup install the CLI if needed, then build the deep graph with the active model
78
+ build [--structural] build graft/ (deep by default; structural stays local)
80
79
  deep provider-backed build: adds summaries and per-symbol crux
81
80
  refresh re-index an existing graph after edits
82
81
  mode [pull|push|hybrid] show or set the retrieval strategy
@@ -145,8 +144,8 @@ export function formatDoctor(input: DoctorInput): string {
145
144
  row(
146
145
  "provider env",
147
146
  input.providerEnv.length > 0
148
- ? `${input.providerEnv.join(", ")} set (values never read or copied) — deep builds use Graft's own provider configuration`
149
- : `none of ${PROVIDER_ENV_NAMES.join(", ")} set — deep builds would need Graft's own provider configuration`,
147
+ ? `${input.providerEnv.join(", ")} set (values never shown) — manual Graft CLI configuration is available`
148
+ : `none of ${PROVIDER_ENV_NAMES.join(", ")} set — automatic deep builds use Selesai's active compatible model`,
150
149
  );
151
150
 
152
151
  const recovery = recoveryFor(input.state);
@@ -185,7 +184,7 @@ export function buildEffects(deep: boolean): string {
185
184
  ];
186
185
  if (deep) {
187
186
  effects.push(
188
- " • sends source-derived file summaries and symbol cruxes through the LLM provider configured in Graft's own environment (GRAFT_PROVIDER / GRAFT_MODEL / GRAFT_BASE_URL / GRAFT_API_KEY)",
187
+ " • sends source-derived file summaries and symbol cruxes through Selesai's active compatible model; credentials are passed only to the Graft child process",
189
188
  " • caches those summaries under graft/ so later deep builds are incremental",
190
189
  );
191
190
  }
@@ -259,9 +258,10 @@ export function registerGraftCommands(pi: ExtensionAPI, runtime: GraftCommandRun
259
258
  const items: ArgumentCompletion[] = [
260
259
  { value: "status", label: "status", description: "availability, graph state, retrieval mode" },
261
260
  { value: "doctor", label: "doctor", description: "full health report and recovery steps" },
262
- { value: "setup", label: "setup", description: "install CLI if needed, then build graft/" },
263
- { value: "build", label: "build", description: "build graft/ (structural; no model, no key)" },
264
- { value: "deep", label: "deep", description: "provider-backed build (needs Graft provider config)" },
261
+ { value: "setup", label: "setup", description: "install CLI if needed, then build the deep graph" },
262
+ { value: "build", label: "build", description: "build graft/ with the active model (default deep)" },
263
+ { value: "build --structural", label: "build --structural", description: "build graft/ locally without model enrichment" },
264
+ { value: "deep", label: "deep", description: "provider-backed build with the active model" },
265
265
  { value: "refresh", label: "refresh", description: "re-index an existing graph after edits" },
266
266
  { value: "mode", label: "mode", description: "show or set pull | push | hybrid" },
267
267
  { value: "help", label: "help", description: "usage" },
@@ -317,7 +317,7 @@ export function registerGraftCommands(pi: ExtensionAPI, runtime: GraftCommandRun
317
317
  case "setup":
318
318
  case "build":
319
319
  case "deep": {
320
- const deep = subcommand === "deep" || rest.includes("--deep");
320
+ const deep = subcommand === "deep" || !rest.includes("--structural");
321
321
  await runtime.recheck(ctx);
322
322
  if (!(await ensureCli(ctx, runtime))) return;
323
323
  const repo = runtime.repoRoot() ?? "this repository";
@@ -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 is different: Graft summarizes each changed file and extracts per-symbol cruxes using the LLM provider configured in Graft's own environment, so source-derived content leaves this machine.\n\nSelesai does not read, copy, or forward its own provider credentials to Graft.\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
  );
@@ -388,5 +388,5 @@ async function runAndReport(
388
388
 
389
389
  /** Where the deep-build guidance points a user whose provider is not configured. */
390
390
  export const DEEP_PROVIDER_HINT =
391
- `Set GRAFT_PROVIDER, GRAFT_MODEL, and GRAFT_API_KEY in your own environment (or a repo .env), then re-run /graft deep. ` +
392
- `${GRAFT_PACKAGE} reads them itself; this extension never forwards Selesai credentials.`;
391
+ `Select a compatible active Selesai model (TokenIn, LiteLLM, OpenAI, Anthropic, or OrcaRouter), then re-run /graft deep. ` +
392
+ `${GRAFT_PACKAGE} receives its credential only in that child process.`;
@@ -12,7 +12,7 @@
12
12
  * - Graft's upstream agent-wiring command (`init`) is never invoked.
13
13
  * - A missing compatible CLI is automatically installed once through npm;
14
14
  * incompatible user installations are never overwritten.
15
- * - Builds require explicit consent; deep (provider-backed) builds need their own.
15
+ * - The first build uses Selesai's active compatible model for deep enrichment; otherwise it stays local and structural.
16
16
  * - Every tool is read-only; the graph is a local cache, not a committed artifact.
17
17
  * - Graft processes default to telemetry opt-out without touching Graft's settings.
18
18
  *
@@ -175,11 +175,109 @@ export function isMutatingResult(event: { toolName: string; input: Record<string
175
175
  return false;
176
176
  }
177
177
 
178
+ const GRAFT_PROVIDER_FOR_SELESAI_PROVIDER: Record<string, "openai" | "anthropic" | "litellm" | "orcarouter"> = {
179
+ anthropic: "anthropic",
180
+ litellm: "litellm",
181
+ openai: "openai",
182
+ orcarouter: "orcarouter",
183
+ tokenin: "litellm",
184
+ };
185
+
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
+
229
+ const model = ctx.model;
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
+ }
238
+
239
+ let apiKey: string | undefined;
240
+ let authBaseUrl: string | undefined;
241
+ try {
242
+ const auth = await ctx.modelRegistry.getProviderAuth(model.provider);
243
+ apiKey = auth?.auth.apiKey;
244
+ authBaseUrl = auth?.auth.baseUrl;
245
+ } catch {
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` };
254
+ }
255
+
256
+ const { GRAFT_PROVIDER: _p, GRAFT_MODEL: _m, GRAFT_API_KEY: _k, GRAFT_BASE_URL: _u, ...env } = process.env;
257
+ return {
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
+ },
266
+ };
267
+ }
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
+
178
274
  export default function graftExtension(pi: ExtensionAPI): void {
179
275
  registerBuiltinAgentAugmentation(pi);
180
276
  const exec: ExecLike = (command, args, options) => pi.exec(command, args, options);
181
277
  let session = freshSession();
182
278
  let installPromise: Promise<GraftInstallRun> | undefined;
279
+ let structuralBuildPromise: Promise<GraftRun> | undefined;
280
+ let structuralBuildSession: GraftSession | undefined;
183
281
  const guard = createInjectionGuard();
184
282
 
185
283
  const applyEvent = (ctx: ExtensionContext | undefined, event: GraftEvent): void => {
@@ -241,6 +339,82 @@ export default function graftExtension(pi: ExtensionAPI): void {
241
339
  }
242
340
  };
243
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
+
384
+ /**
385
+ * Start the graph once. Compatible active Selesai models add the deep tier;
386
+ * otherwise the free local structural tier remains usable.
387
+ */
388
+ const autoBuild = (ctx: ExtensionContext, activeSession: GraftSession): Promise<GraftRun> | undefined => {
389
+ if (structuralBuildPromise && structuralBuildSession === activeSession) return structuralBuildPromise;
390
+ if (
391
+ !activeSession.repoRoot ||
392
+ activeSession.probe?.kind !== "ok" ||
393
+ activeSession.presence.built ||
394
+ activeSession.state.name === "failed"
395
+ ) return undefined;
396
+
397
+ const pending = buildGraph(ctx, activeSession);
398
+ structuralBuildPromise = pending;
399
+ structuralBuildSession = activeSession;
400
+ void pending.then(
401
+ (run) => {
402
+ if (session === activeSession && !activeSession.disposed) recordBuild(ctx, run, run.op.kind === "build" && run.op.deep);
403
+ },
404
+ (error) => {
405
+ if (session === activeSession && !activeSession.disposed) {
406
+ applyEvent(ctx, { type: "build-failed", detail: error instanceof Error ? error.message : String(error) });
407
+ }
408
+ },
409
+ ).finally(() => {
410
+ if (structuralBuildPromise === pending && structuralBuildSession === activeSession) {
411
+ structuralBuildPromise = undefined;
412
+ structuralBuildSession = undefined;
413
+ }
414
+ });
415
+ return pending;
416
+ };
417
+
244
418
  /** Install a missing CLI without blocking session startup or racing another session. */
245
419
  const autoInstall = (ctx: ExtensionContext, activeSession: GraftSession): void => {
246
420
  if (ctx.hasUI) ctx.ui.notify("Installing Graft CLI…", "info");
@@ -258,6 +432,7 @@ export default function graftExtension(pi: ExtensionAPI): void {
258
432
  if (ctx.hasUI) ctx.ui.notify("Graft CLI installed, but is not yet usable. Restart Selesai and run /graft doctor.", "warning");
259
433
  return;
260
434
  }
435
+ autoBuild(ctx, activeSession);
261
436
  if (ctx.hasUI) ctx.ui.notify("Graft CLI installed.", "info");
262
437
  })();
263
438
  };
@@ -285,11 +460,14 @@ export default function graftExtension(pi: ExtensionAPI): void {
285
460
  throw new Error(`Graft is not usable here: ${detail} Run /graft doctor for a recovery step.`);
286
461
  }
287
462
  if (!session.presence.built && op.kind !== "check-freshness" && op.kind !== "build") {
288
- throw new Error(
289
- `There is no ${GRAPH_DIR_NAME}/ graph in ${session.repoRoot} yet. ${
290
- recoveryFor(session.state) ?? "Run /graft build to index this repository."
291
- }`,
292
- );
463
+ await autoBuild(ctx, session);
464
+ if (!session.presence.built) {
465
+ throw new Error(
466
+ `There is no ${GRAPH_DIR_NAME}/ graph in ${session.repoRoot} yet. ${
467
+ recoveryFor(session.state) ?? "Run /graft build to retry the structural index."
468
+ }`,
469
+ );
470
+ }
293
471
  }
294
472
  };
295
473
 
@@ -337,6 +515,7 @@ export default function graftExtension(pi: ExtensionAPI): void {
337
515
 
338
516
  await recheck(ctx);
339
517
  if (session.state.name === "unavailable") autoInstall(ctx, session);
518
+ else autoBuild(ctx, session);
340
519
  });
341
520
 
342
521
  pi.on("session_shutdown", (_event: SessionShutdownEvent, ctx: ExtensionContext) => {
@@ -450,9 +629,8 @@ export default function graftExtension(pi: ExtensionAPI): void {
450
629
  * Debounced, idle-aware proactive refresh.
451
630
  *
452
631
  * `agent_end` is the runtime's own idle boundary, so this needs no timer: a
453
- * burst of edits inside one turn costs at most one rebuild, and a structural
454
- * rebuild is free and offline. Refresh never *creates* a graph — an unbuilt
455
- * repository stays unbuilt until the user consents to a build.
632
+ * burst of edits inside one turn costs at most one rebuild. A compatible
633
+ * active Selesai model adds the deep tier; otherwise refresh stays local.
456
634
  */
457
635
  pi.on("agent_end", (_event, ctx) => {
458
636
  if (!session.enabled || session.disposed) return;
@@ -466,9 +644,9 @@ export default function graftExtension(pi: ExtensionAPI): void {
466
644
 
467
645
  void (async () => {
468
646
  try {
469
- const run = await runGraft(exec, session.repoRoot!, { kind: "build", deep: false });
647
+ const run = await buildGraph(ctx, session, "refresh");
470
648
  if (session.disposed) return;
471
- recordBuild(ctx, run, false);
649
+ recordBuild(ctx, run, run.op.kind === "build" && run.op.deep);
472
650
  } catch (error) {
473
651
  if (session.disposed) return;
474
652
  applyEvent(ctx, {
@@ -501,9 +679,30 @@ export default function graftExtension(pi: ExtensionAPI): void {
501
679
  installCli,
502
680
  runBuild: async (deep, ctx) => {
503
681
  await ensureReady(ctx, { kind: "build", deep });
504
- applyEvent(ctx, { type: "build-started", deep });
505
- const run = await runGraft(exec, session.repoRoot!, { kind: "build", deep });
506
- recordBuild(ctx, run, deep);
682
+ if (!deep) {
683
+ const pending = autoBuild(ctx, session);
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;
689
+ }
690
+ const plan = await resolveDeepBuildPlan(ctx, session.settings);
691
+ if (!plan.ok) {
692
+ return {
693
+ op: { kind: "build", deep: true },
694
+ argv: ["graft", "build", "--deep"],
695
+ cwd: session.repoRoot!,
696
+ code: 1,
697
+ stdout: "",
698
+ stderr: `No deep build is possible for the active model: ${plan.reason}.`,
699
+ killed: false,
700
+ cancelled: false,
701
+ timedOut: false,
702
+ };
703
+ }
704
+ const run = await buildGraph(ctx, session);
705
+ recordBuild(ctx, run, run.op.kind === "build" && run.op.deep);
507
706
  return run;
508
707
  },
509
708
  runRefresh: async (ctx) => {
@@ -511,8 +710,8 @@ export default function graftExtension(pi: ExtensionAPI): void {
511
710
  await ensureReady(ctx, { kind: "build", deep: false });
512
711
  session.lastRefreshAt = Date.now();
513
712
  applyEvent(ctx, { type: "refresh-started" });
514
- const run = await runGraft(exec, session.repoRoot!, { kind: "build", deep: false });
515
- recordBuild(ctx, run, false);
713
+ const run = await buildGraph(ctx, session, "refresh");
714
+ recordBuild(ctx, run, run.op.kind === "build" && run.op.deep);
516
715
  return run;
517
716
  },
518
717
  providerEnvNames: () => PROVIDER_ENV_NAMES.filter((name) => Boolean(process.env[name])),
@@ -263,7 +263,7 @@ describe("proactive refresh", () => {
263
263
  await session.reset();
264
264
  });
265
265
 
266
- it("never creates a graph that does not exist", async () => {
266
+ it("automatically creates the initial local structural graph", async () => {
267
267
  process.env.PI_CODING_AGENT_DIR = agentDir;
268
268
  const bare = join(root, "bare-repo");
269
269
  mkdirSync(join(bare, ".selesai"), { recursive: true });
@@ -271,6 +271,10 @@ describe("proactive refresh", () => {
271
271
  const harness = makePi(async (command: string, args: string[]) => {
272
272
  if (command === "git") return { ...OK, stdout: `${bare}\n` };
273
273
  if (args.includes("--version")) return { ...OK, stdout: "0.18.0\n" };
274
+ if (args.includes("build")) {
275
+ mkdirSync(join(bare, "graft", ".graph"), { recursive: true });
276
+ writeFileSync(join(bare, "graft", ".graph", "wiring.json"), "{}", "utf-8");
277
+ }
274
278
  return { ...OK, stdout: ASK_JSON };
275
279
  });
276
280
  graftExtension(harness.pi as never);
@@ -279,10 +283,114 @@ describe("proactive refresh", () => {
279
283
  { type: "session_start", reason: "startup" },
280
284
  ctx,
281
285
  );
282
- expect(ctx.statuses.get("graft")).toBe("graft: ○ unbuilt v0.18.0");
283
- const before = harness.exec.mock.calls.length;
284
- await (handlerFor(harness, "agent_end") as (e: unknown, c: unknown) => Promise<void>)({}, ctx);
285
- expect(harness.exec.mock.calls.length).toBe(before);
286
+ await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ● structural v0.18.0"));
287
+ expect(harness.commandsRun()).toContain("graft build");
288
+ });
289
+
290
+ it("uses the active TokenIn model for the default deep build", async () => {
291
+ process.env.PI_CODING_AGENT_DIR = agentDir;
292
+ const bare = join(root, "deep-repo");
293
+ mkdirSync(join(bare, ".selesai"), { recursive: true });
294
+ writeFileSync(join(bare, ".selesai", "settings.json"), JSON.stringify({ graft: {} }), "utf-8");
295
+ const harness = makePi(async (command: string, args: string[]) => {
296
+ if (command === "git") return { ...OK, stdout: `${bare}\n` };
297
+ if (args.includes("--version")) return { ...OK, stdout: "0.18.0\n" };
298
+ if (args.includes("build")) {
299
+ mkdirSync(join(bare, "graft", ".graph"), { recursive: true });
300
+ writeFileSync(join(bare, "graft", ".graph", "wiring.json"), "{}", "utf-8");
301
+ writeFileSync(join(bare, "graft", "concept.md"), "# Concept", "utf-8");
302
+ }
303
+ return { ...OK, stdout: ASK_JSON };
304
+ });
305
+ graftExtension(harness.pi as never);
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.
309
+ Object.assign(ctx, {
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
+ });
313
+ await (handlerFor(harness, "session_start") as (e: unknown, c: unknown) => Promise<void>)(
314
+ { type: "session_start", reason: "startup" },
315
+ ctx,
316
+ );
317
+ await vi.waitFor(() => expect(ctx.statuses.get("graft")).toBe("graft: ● deep v0.18.0"));
318
+ const build = harness.exec.mock.calls.find((call) => (call[1] as string[]).includes("build"))!;
319
+ expect(build[1]).toEqual(["build", "--deep"]);
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"]);
286
394
  });
287
395
 
288
396
  it("re-indexes after edits, shows the sync phase, and returns to fresh", async () => {
@@ -352,7 +460,7 @@ describe("retrieval strategy", () => {
352
460
  session.ctx,
353
461
  );
354
462
 
355
- it("injects a source-backed pack in push mode, which is the default", async () => {
463
+ it("injects a source-backed orientation pack in the default hybrid mode", async () => {
356
464
  const session = await bootSession();
357
465
  const result = await beforeAgentStart(session, "Fix the login crash");
358
466
  expect(result).toBeDefined();
@@ -450,7 +558,7 @@ describe("retrieval strategy", () => {
450
558
  expect(notified(ctx)).toContain("Graft context unavailable");
451
559
  });
452
560
 
453
- it("injects nothing when the graph has never been built, and says how to fix it", async () => {
561
+ it("waits for the initial structural build before injecting context", async () => {
454
562
  process.env.PI_CODING_AGENT_DIR = agentDir;
455
563
  const bare = join(root, "unbuilt-repo");
456
564
  mkdirSync(join(bare, ".selesai"), { recursive: true });
@@ -458,6 +566,10 @@ describe("retrieval strategy", () => {
458
566
  const harness = makePi(async (command: string, args: string[]) => {
459
567
  if (command === "git") return { ...OK, stdout: `${bare}\n` };
460
568
  if (args.includes("--version")) return { ...OK, stdout: "0.18.0\n" };
569
+ if (args.includes("build")) {
570
+ mkdirSync(join(bare, "graft", ".graph"), { recursive: true });
571
+ writeFileSync(join(bare, "graft", ".graph", "wiring.json"), "{}", "utf-8");
572
+ }
461
573
  return { ...OK, stdout: ASK_JSON };
462
574
  });
463
575
  graftExtension(harness.pi as never);
@@ -471,7 +583,7 @@ describe("retrieval strategy", () => {
471
583
  { type: "before_agent_start", prompt: "Fix the login crash" },
472
584
  ctx,
473
585
  ),
474
- ).toBeUndefined();
586
+ ).toMatchObject({ message: { customType: "graft-context" } });
475
587
  });
476
588
  });
477
589
 
@@ -523,7 +635,7 @@ describe("tool readiness", () => {
523
635
  ).rejects.toThrow(/Run \/graft doctor/);
524
636
  });
525
637
 
526
- it("tells the user to build before answering from a graph that is not there", async () => {
638
+ it("builds before answering from a graph that is not there", async () => {
527
639
  process.env.PI_CODING_AGENT_DIR = agentDir;
528
640
  const bare = join(root, "unbuilt-tools");
529
641
  mkdirSync(join(bare, ".selesai"), { recursive: true });
@@ -531,6 +643,10 @@ describe("tool readiness", () => {
531
643
  const harness = makePi(async (command: string, args: string[]) => {
532
644
  if (command === "git") return { ...OK, stdout: `${bare}\n` };
533
645
  if (args.includes("--version")) return { ...OK, stdout: "0.18.0\n" };
646
+ if (args.includes("build")) {
647
+ mkdirSync(join(bare, "graft", ".graph"), { recursive: true });
648
+ writeFileSync(join(bare, "graft", ".graph", "wiring.json"), "{}", "utf-8");
649
+ }
534
650
  return { ...OK, stdout: ASK_JSON };
535
651
  });
536
652
  graftExtension(harness.pi as never);
@@ -542,7 +658,8 @@ describe("tool readiness", () => {
542
658
  const tool = toolFor(harness, "graft_find_code");
543
659
  await expect(
544
660
  (tool.execute as (...args: unknown[]) => Promise<unknown>)("c", { question: "x" }, undefined, undefined, ctx),
545
- ).rejects.toThrow(/graft build/);
661
+ ).resolves.toBeDefined();
662
+ expect(harness.commandsRun()).toContain("graft build");
546
663
  });
547
664
 
548
665
  it("re-resolves lazily when an earlier attempt found no repository", async () => {
@@ -170,8 +170,8 @@ describe("retrieval mode", () => {
170
170
  expect(isRetrievalMode(undefined)).toBe(false);
171
171
  });
172
172
 
173
- it("defaults to push, the source-backed context mode", () => {
174
- expect(DEFAULT_RETRIEVAL_MODE).toBe("push");
173
+ it("defaults to hybrid, the relevance-gated context mode", () => {
174
+ expect(DEFAULT_RETRIEVAL_MODE).toBe("hybrid");
175
175
  });
176
176
 
177
177
  it("restores the newest mode chosen on this branch, ignoring other custom entries", () => {
@@ -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" });
@@ -30,7 +30,7 @@ import { INSTALL_COMMAND } from "./cli.ts";
30
30
  export type RetrievalMode = "pull" | "push" | "hybrid";
31
31
 
32
32
  export const RETRIEVAL_MODES: readonly RetrievalMode[] = ["pull", "push", "hybrid"];
33
- export const DEFAULT_RETRIEVAL_MODE: RetrievalMode = "push";
33
+ export const DEFAULT_RETRIEVAL_MODE: RetrievalMode = "hybrid";
34
34
 
35
35
  export function isRetrievalMode(value: unknown): value is RetrievalMode {
36
36
  return typeof value === "string" && (RETRIEVAL_MODES as readonly string[]).includes(value);
@@ -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
 
@@ -12,8 +12,9 @@
12
12
  * graft_check_freshness → graft check --json
13
13
  *
14
14
  * Every one of them is read-only under Selesai's capability model. Nothing here
15
- * can build, mutate, install, or configure anything — the build operations live
16
- * behind the consent-gated commands in `commands.ts`.
15
+ * can build, mutate, install, or configure anything — the extension builds the
16
+ * graph during session setup, using the active compatible Selesai model for the
17
+ * optional deep tier.
17
18
  */
18
19
 
19
20
  import { StringEnum } from "@earendil-works/pi-ai";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selesai/code",
3
- "version": "0.13.23",
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": {