@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 +19 -0
- package/dist/core/exec.d.ts +2 -0
- package/dist/core/exec.js +1 -0
- package/dist/extensions/pi-graft/cli.test.ts +23 -0
- package/dist/extensions/pi-graft/cli.ts +6 -5
- package/dist/extensions/pi-graft/commands.test.ts +16 -14
- package/dist/extensions/pi-graft/commands.ts +17 -17
- package/dist/extensions/pi-graft/index.ts +215 -16
- package/dist/extensions/pi-graft/lifecycle.test.ts +127 -10
- package/dist/extensions/pi-graft/state.test.ts +8 -2
- package/dist/extensions/pi-graft/state.ts +7 -1
- package/dist/extensions/pi-graft/tools.ts +3 -2
- package/package.json +1 -1
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
|
package/dist/core/exec.d.ts
CHANGED
package/dist/core/exec.js
CHANGED
|
@@ -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.
|
|
15
|
-
*
|
|
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
|
|
148
|
-
*
|
|
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
|
|
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
|
|
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("
|
|
209
|
-
expect(text).toContain("Graft
|
|
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
|
|
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("
|
|
301
|
-
expect(harness.runBuild).toHaveBeenCalledWith(
|
|
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(
|
|
348
|
+
expect(harness.runBuild).toHaveBeenCalledWith(true, ctx);
|
|
349
349
|
expect(notified(ctx)).toContain("Done.");
|
|
350
350
|
});
|
|
351
351
|
|
|
352
|
-
it("
|
|
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(
|
|
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
|
|
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(
|
|
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("
|
|
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("
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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.
|
|
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
|
|
79
|
-
build
|
|
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
|
|
149
|
-
: `none of ${PROVIDER_ENV_NAMES.join(", ")} set — deep builds
|
|
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
|
|
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
|
|
263
|
-
{ value: "build", label: "build", description: "build graft/
|
|
264
|
-
{ value: "
|
|
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("--
|
|
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
|
|
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
|
-
`
|
|
392
|
-
`${GRAFT_PACKAGE}
|
|
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
|
-
* -
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
454
|
-
*
|
|
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
|
|
647
|
+
const run = await buildGraph(ctx, session, "refresh");
|
|
470
648
|
if (session.disposed) return;
|
|
471
|
-
recordBuild(ctx, run,
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
|
515
|
-
recordBuild(ctx, run,
|
|
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("
|
|
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:
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
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("
|
|
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
|
-
).
|
|
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("
|
|
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
|
-
).
|
|
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
|
|
174
|
-
expect(DEFAULT_RETRIEVAL_MODE).toBe("
|
|
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 = "
|
|
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
|
|
16
|
-
*
|
|
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.
|
|
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": {
|