pi-sage 0.2.0

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.
@@ -0,0 +1,659 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import {
4
+ evaluateSoftBudget,
5
+ isEligibleCaller,
6
+ isHardCostCapExceeded,
7
+ makeBlockedResult
8
+ } from "./policy.js";
9
+ import { isSageRunnerPolicyError, runSageSingleShot } from "./runner.js";
10
+ import {
11
+ getSettingsPathForScope,
12
+ loadSettings,
13
+ mergeSettings,
14
+ saveSettings,
15
+ type SageSettings,
16
+ type SettingsScope
17
+ } from "./settings.js";
18
+ import { checkVolumeCaps, getDisallowedCustomTools, resolveToolPolicy } from "./tool-policy.js";
19
+ import type {
20
+ BlockCode,
21
+ CallerContext,
22
+ ReasoningLevel,
23
+ SageBudgetState,
24
+ SageConsultInput,
25
+ SageMode,
26
+ SageToolResult,
27
+ ToolProfile
28
+ } from "./types.js";
29
+
30
+ type InputSource = "interactive" | "rpc" | "extension";
31
+
32
+ type ModelLike = {
33
+ provider: string;
34
+ id: string;
35
+ name?: string;
36
+ };
37
+
38
+ const ROLE_HINT_ENV_KEYS = ["TASKPLANE_ROLE", "TASKPLANE_AGENT_ROLE", "PI_AGENT_ROLE", "ORCH_AGENT_ROLE"];
39
+
40
+ const REASONING_LEVELS: ReasoningLevel[] = ["minimal", "low", "medium", "high", "xhigh"];
41
+ const TOOL_PROFILES: ToolProfile[] = ["none", "read-only-lite", "git-review-readonly", "custom-read-only"];
42
+
43
+ const SAGE_GUIDANCE = [
44
+ "You may call `sage_consult` for high-complexity reasoning, ambiguous debugging, risky architectural decisions, or explicit second-opinion requests.",
45
+ "Do not overuse Sage for routine edits.",
46
+ "Sage is advisory-only: treat output as recommendations and implement changes yourself.",
47
+ "Use one focused Sage question per consultation.",
48
+ "If user explicitly requests Sage/second opinion, prefer at least one Sage consultation unless blocked by hard safety limits."
49
+ ].join("\n- ");
50
+
51
+ export default function registerSageExtension(pi: ExtensionAPI): void {
52
+ if (process.env.PI_SAGE_SUBAGENT === "1") {
53
+ return;
54
+ }
55
+
56
+ const budgetState: SageBudgetState = {
57
+ currentTurn: 0,
58
+ callsThisTurn: 0,
59
+ sessionCalls: 0,
60
+ lastAutoTurn: undefined,
61
+ sessionCostTotal: 0
62
+ };
63
+
64
+ let lastInputSource: InputSource | undefined;
65
+
66
+ pi.on("input", (event) => {
67
+ if (event.source === "interactive" || event.source === "rpc" || event.source === "extension") {
68
+ lastInputSource = event.source;
69
+ }
70
+ return { action: "continue" };
71
+ });
72
+
73
+ pi.on("turn_start", () => {
74
+ budgetState.currentTurn += 1;
75
+ budgetState.callsThisTurn = 0;
76
+ });
77
+
78
+ pi.on("before_agent_start", (event, ctx) => {
79
+ const loaded = loadSettings(ctx.cwd);
80
+ if (loaded.settings.enabled === false) return undefined;
81
+
82
+ return {
83
+ systemPrompt: `${event.systemPrompt}\n\nSage guidance:\n- ${SAGE_GUIDANCE}`
84
+ };
85
+ });
86
+
87
+ pi.registerCommand("sage-settings", {
88
+ description: "Configure Sage model, limits, and policies",
89
+ handler: async (_args, ctx) => {
90
+ await runSettingsWizard(ctx);
91
+ }
92
+ });
93
+
94
+ pi.registerTool({
95
+ name: "sage_consult",
96
+ label: "Sage Consult",
97
+ description: "Consult an isolated high-reasoning Sage subagent for advisory analysis.",
98
+ promptSnippet: "Consult Sage for complex second-opinion reasoning.",
99
+ promptGuidelines: [
100
+ "Use Sage for high-impact ambiguous tasks or explicit second-opinion requests.",
101
+ "Set force=true when the user explicitly asks for Sage to bypass soft limits.",
102
+ "After Sage returns, synthesize recommendations and continue execution."
103
+ ],
104
+ parameters: Type.Object({
105
+ question: Type.String({ description: "Focused question to ask Sage" }),
106
+ context: Type.Optional(Type.String({ description: "Compressed relevant context" })),
107
+ files: Type.Optional(Type.Array(Type.String(), { description: "Relevant file paths" })),
108
+ evidence: Type.Optional(Type.Array(Type.String(), { description: "Logs, errors, or observations" })),
109
+ objective: Type.Optional(
110
+ Type.Union([
111
+ Type.Literal("debug"),
112
+ Type.Literal("design"),
113
+ Type.Literal("review"),
114
+ Type.Literal("refactor"),
115
+ Type.Literal("general")
116
+ ])
117
+ ),
118
+ urgency: Type.Optional(
119
+ Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")])
120
+ ),
121
+ force: Type.Optional(
122
+ Type.Boolean({ description: "Bypass soft limits for explicit user-requested consultations" })
123
+ )
124
+ }),
125
+ async execute(_toolCallId, params, signal, _onUpdate, ctx): Promise<SageToolResult> {
126
+ const input = params as SageConsultInput;
127
+ const loaded = loadSettings(ctx.cwd);
128
+ const settings = loaded.settings;
129
+ const mode: SageMode = input.force ? "user-requested" : "autonomous";
130
+
131
+ const modelSelection = resolveModelSpec(settings.model, ctx.modelRegistry.getAvailable(), ctx.model);
132
+ const resolvedModel = modelSelection.modelArg;
133
+
134
+ if (!settings.enabled) {
135
+ return makeBlockedResult({
136
+ mode,
137
+ model: resolvedModel,
138
+ reasoningLevel: settings.reasoningLevel,
139
+ blockCode: "disabled",
140
+ reason: "Sage is disabled",
141
+ allowedByContext: true,
142
+ allowedByBudget: false
143
+ });
144
+ }
145
+
146
+ const callerContext = buildCallerContext(lastInputSource, ctx.hasUI);
147
+ const eligibility = isEligibleCaller(callerContext);
148
+ if (!eligibility.ok) {
149
+ return makeBlockedResult({
150
+ mode,
151
+ model: resolvedModel,
152
+ reasoningLevel: settings.reasoningLevel,
153
+ blockCode: eligibility.blockCode ?? "unknown-context",
154
+ reason: eligibility.reason,
155
+ allowedByContext: false,
156
+ allowedByBudget: false
157
+ });
158
+ }
159
+
160
+ if (!resolvedModel) {
161
+ return makeBlockedResult({
162
+ mode,
163
+ model: "unavailable",
164
+ reasoningLevel: settings.reasoningLevel,
165
+ blockCode: "model-unavailable",
166
+ reason: "No available model configured for Sage",
167
+ allowedByContext: true,
168
+ allowedByBudget: false
169
+ });
170
+ }
171
+
172
+ if (isHardCostCapExceeded(settings, budgetState)) {
173
+ return makeBlockedResult({
174
+ mode,
175
+ model: resolvedModel,
176
+ reasoningLevel: settings.reasoningLevel,
177
+ blockCode: "cost-cap",
178
+ reason: "Sage session cost cap exceeded",
179
+ allowedByContext: true,
180
+ allowedByBudget: false
181
+ });
182
+ }
183
+
184
+ const softBudget = evaluateSoftBudget({
185
+ settings,
186
+ budgetState,
187
+ mode,
188
+ force: input.force === true,
189
+ questionLength: input.question.length,
190
+ contextLength: input.context?.length ?? 0
191
+ });
192
+
193
+ if (!softBudget.ok) {
194
+ return makeBlockedResult({
195
+ mode,
196
+ model: resolvedModel,
197
+ reasoningLevel: settings.reasoningLevel,
198
+ blockCode: "soft-limit",
199
+ reason: softBudget.reason,
200
+ allowedByContext: true,
201
+ allowedByBudget: false
202
+ });
203
+ }
204
+
205
+ const disallowedCustomTools = getDisallowedCustomTools(settings.toolPolicy.customAllowedTools);
206
+ if (disallowedCustomTools.length > 0) {
207
+ return makeBlockedResult({
208
+ mode,
209
+ model: resolvedModel,
210
+ reasoningLevel: settings.reasoningLevel,
211
+ blockCode: "tool-disallowed",
212
+ reason: `Custom tool list contains disallowed tools: ${disallowedCustomTools.join(", ")}`,
213
+ allowedByContext: true,
214
+ allowedByBudget: false
215
+ });
216
+ }
217
+
218
+ const toolPolicy = resolveToolPolicy(settings.toolPolicy);
219
+
220
+ try {
221
+ const runnerResult = await runSageSingleShot({
222
+ cwd: ctx.cwd,
223
+ model: resolvedModel,
224
+ reasoningLevel: settings.reasoningLevel,
225
+ timeoutMs: settings.timeoutMs,
226
+ question: input.question,
227
+ context: input.context,
228
+ files: input.files,
229
+ evidence: input.evidence,
230
+ objective: input.objective,
231
+ urgency: input.urgency,
232
+ toolPolicy: settings.toolPolicy,
233
+ signal
234
+ });
235
+
236
+ const volume = checkVolumeCaps(runnerResult.toolUsage, toolPolicy);
237
+ if (!volume.ok) {
238
+ return makeBlockedResult({
239
+ mode,
240
+ model: resolvedModel,
241
+ reasoningLevel: settings.reasoningLevel,
242
+ blockCode: volume.blockCode ?? "volume-cap",
243
+ reason: volume.reason,
244
+ allowedByContext: true,
245
+ allowedByBudget: false
246
+ });
247
+ }
248
+
249
+ budgetState.callsThisTurn += 1;
250
+ budgetState.sessionCalls += 1;
251
+ if (mode === "autonomous") budgetState.lastAutoTurn = budgetState.currentTurn;
252
+ if (runnerResult.usage?.costTotal) budgetState.sessionCostTotal += runnerResult.usage.costTotal;
253
+
254
+ return {
255
+ content: [{ type: "text", text: runnerResult.text }],
256
+ details: {
257
+ model: resolvedModel,
258
+ reasoningLevel: settings.reasoningLevel,
259
+ latencyMs: runnerResult.latencyMs,
260
+ stopReason: runnerResult.stopReason,
261
+ usage: runnerResult.usage,
262
+ toolUsage: runnerResult.toolUsage,
263
+ policy: {
264
+ mode,
265
+ allowedByContext: true,
266
+ contextReason: "eligible",
267
+ allowedByBudget: true,
268
+ budgetReason: softBudget.reason
269
+ }
270
+ }
271
+ };
272
+ } catch (error) {
273
+ const message = error instanceof Error ? error.message : "Unknown Sage failure";
274
+
275
+ if (isSageRunnerPolicyError(error)) {
276
+ return makeBlockedResult({
277
+ mode,
278
+ model: resolvedModel,
279
+ reasoningLevel: settings.reasoningLevel,
280
+ blockCode: error.blockCode,
281
+ reason: error.message,
282
+ allowedByContext: true,
283
+ allowedByBudget: false
284
+ });
285
+ }
286
+
287
+ if (message.toLowerCase().includes("timed out")) {
288
+ return makeBlockedResult({
289
+ mode,
290
+ model: resolvedModel,
291
+ reasoningLevel: settings.reasoningLevel,
292
+ blockCode: "timeout",
293
+ reason: message,
294
+ allowedByContext: true,
295
+ allowedByBudget: false
296
+ });
297
+ }
298
+
299
+ const blockCode: BlockCode = "execution-error";
300
+ return {
301
+ content: [{ type: "text", text: `Sage execution failed: ${message}` }],
302
+ details: {
303
+ model: resolvedModel,
304
+ reasoningLevel: settings.reasoningLevel,
305
+ latencyMs: 0,
306
+ stopReason: "error",
307
+ policy: {
308
+ mode,
309
+ allowedByContext: true,
310
+ contextReason: "eligible",
311
+ blockCode,
312
+ allowedByBudget: true,
313
+ budgetReason: softBudget.reason
314
+ }
315
+ },
316
+ isError: true
317
+ };
318
+ }
319
+ }
320
+ });
321
+ }
322
+
323
+ function buildCallerContext(lastInputSource: InputSource | undefined, hasUI: boolean): CallerContext | null {
324
+ if (!lastInputSource) return null;
325
+
326
+ const roleHint = getRoleHint();
327
+ const isRpcSource = lastInputSource === "rpc";
328
+ const isSubagent = process.env.PI_SAGE_SUBAGENT === "1";
329
+ const interactive = hasUI && lastInputSource === "interactive";
330
+
331
+ return {
332
+ session: {
333
+ interactive
334
+ },
335
+ agent: {
336
+ role: roleHint ?? "primary",
337
+ isSubagent,
338
+ isRpcOrchestrated: isRpcSource || Boolean(roleHint && roleHint !== "primary")
339
+ },
340
+ runtime: {
341
+ mode: process.env.CI ? "ci" : hasUI ? (isRpcSource ? "rpc" : "interactive") : "non-interactive"
342
+ }
343
+ };
344
+ }
345
+
346
+ function getRoleHint(): string | undefined {
347
+ for (const key of ROLE_HINT_ENV_KEYS) {
348
+ const value = process.env[key];
349
+ if (value) return value.trim().toLowerCase();
350
+ }
351
+ return undefined;
352
+ }
353
+
354
+ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
355
+ if (!ctx.hasUI) {
356
+ ctx.ui.notify("/sage-settings requires interactive UI", "warning");
357
+ return;
358
+ }
359
+
360
+ const loaded = loadSettings(ctx.cwd);
361
+ let draft = mergeSettings(loaded.settings);
362
+ let scope: SettingsScope = loaded.source === "global" ? "global" : "project";
363
+
364
+ while (true) {
365
+ const action = await ctx.ui.select("Sage settings", [
366
+ `Enabled: ${onOff(draft.enabled)}`,
367
+ `Autonomous mode: ${onOff(draft.autonomousEnabled)}`,
368
+ `Model: ${draft.model}`,
369
+ `Reasoning level: ${draft.reasoningLevel}`,
370
+ `Timeout ms: ${draft.timeoutMs}`,
371
+ `Max calls/turn: ${draft.maxCallsPerTurn}`,
372
+ `Max calls/session: ${draft.maxCallsPerSession}`,
373
+ `Cooldown turns: ${draft.cooldownTurnsBetweenAutoCalls}`,
374
+ `Tool profile: ${draft.toolPolicy.profile}`,
375
+ `Custom tools: ${(draft.toolPolicy.customAllowedTools ?? []).join(",") || "(none)"}`,
376
+ `Max tool calls: ${draft.toolPolicy.maxToolCalls ?? 10}`,
377
+ `Max files read: ${draft.toolPolicy.maxFilesRead ?? 8}`,
378
+ `Max bytes/file: ${draft.toolPolicy.maxBytesPerFile ?? 200 * 1024}`,
379
+ `Max total bytes: ${draft.toolPolicy.maxTotalBytesRead ?? 1024 * 1024}`,
380
+ `Sensitive denylist: ${(draft.toolPolicy.sensitivePathDenylist ?? []).join(",")}`,
381
+ `Cost cap/session: ${draft.maxEstimatedCostPerSession ?? "(none)"}`,
382
+ `Save scope: ${scope}`,
383
+ "Test Sage call",
384
+ "Save and exit",
385
+ "Exit without saving"
386
+ ]);
387
+
388
+ if (!action || action === "Exit without saving") {
389
+ ctx.ui.notify("Sage settings unchanged", "info");
390
+ return;
391
+ }
392
+
393
+ if (action === "Save and exit") {
394
+ const target = getSettingsPathForScope(ctx.cwd, scope);
395
+ saveSettings(target, draft);
396
+ ctx.ui.notify(`Saved Sage settings to ${target}`, "info");
397
+ return;
398
+ }
399
+
400
+ if (action === "Test Sage call") {
401
+ await runSettingsTestCall(ctx, draft);
402
+ continue;
403
+ }
404
+
405
+ if (action.startsWith("Enabled:")) {
406
+ draft = { ...draft, enabled: !draft.enabled };
407
+ continue;
408
+ }
409
+ if (action.startsWith("Autonomous mode:")) {
410
+ draft = { ...draft, autonomousEnabled: !draft.autonomousEnabled };
411
+ continue;
412
+ }
413
+ if (action.startsWith("Model:")) {
414
+ const selected = await pickModel(ctx);
415
+ if (selected) draft = { ...draft, model: selected };
416
+ continue;
417
+ }
418
+ if (action.startsWith("Reasoning level:")) {
419
+ const selected = await ctx.ui.select("Reasoning level", [...REASONING_LEVELS]);
420
+ if (selected && REASONING_LEVELS.includes(selected as ReasoningLevel)) {
421
+ draft = { ...draft, reasoningLevel: selected as ReasoningLevel };
422
+ }
423
+ continue;
424
+ }
425
+ if (action.startsWith("Timeout ms:")) {
426
+ draft = await setNumberSetting(ctx, draft, "timeoutMs", "Timeout in milliseconds", 1000);
427
+ continue;
428
+ }
429
+ if (action.startsWith("Max calls/turn:")) {
430
+ draft = await setNumberSetting(ctx, draft, "maxCallsPerTurn", "Max Sage calls per turn", 1);
431
+ continue;
432
+ }
433
+ if (action.startsWith("Max calls/session:")) {
434
+ draft = await setNumberSetting(ctx, draft, "maxCallsPerSession", "Max Sage calls per session", 1);
435
+ continue;
436
+ }
437
+ if (action.startsWith("Cooldown turns:")) {
438
+ draft = await setNumberSetting(ctx, draft, "cooldownTurnsBetweenAutoCalls", "Cooldown turns", 0);
439
+ continue;
440
+ }
441
+ if (action.startsWith("Tool profile:")) {
442
+ const selected = await ctx.ui.select("Tool profile", [...TOOL_PROFILES]);
443
+ if (selected && TOOL_PROFILES.includes(selected as ToolProfile)) {
444
+ draft = { ...draft, toolPolicy: { ...draft.toolPolicy, profile: selected as ToolProfile } };
445
+ }
446
+ continue;
447
+ }
448
+ if (action.startsWith("Custom tools:")) {
449
+ const value = await ctx.ui.input("Comma-separated custom tools", "ls,glob,grep,read");
450
+ if (value !== undefined) {
451
+ const tools = value
452
+ .split(",")
453
+ .map((item) => item.trim())
454
+ .filter(Boolean);
455
+ draft = { ...draft, toolPolicy: { ...draft.toolPolicy, customAllowedTools: tools } };
456
+ }
457
+ continue;
458
+ }
459
+ if (action.startsWith("Max tool calls:")) {
460
+ draft = await setToolPolicyNumberSetting(ctx, draft, "maxToolCalls", "Max tool calls", 1);
461
+ continue;
462
+ }
463
+ if (action.startsWith("Max files read:")) {
464
+ draft = await setToolPolicyNumberSetting(ctx, draft, "maxFilesRead", "Max files read", 1);
465
+ continue;
466
+ }
467
+ if (action.startsWith("Max bytes/file:")) {
468
+ draft = await setToolPolicyNumberSetting(ctx, draft, "maxBytesPerFile", "Max bytes per file", 1024);
469
+ continue;
470
+ }
471
+ if (action.startsWith("Max total bytes:")) {
472
+ draft = await setToolPolicyNumberSetting(ctx, draft, "maxTotalBytesRead", "Max total bytes", 1024);
473
+ continue;
474
+ }
475
+ if (action.startsWith("Sensitive denylist:")) {
476
+ const value = await ctx.ui.input(
477
+ "Comma-separated sensitive path denylist",
478
+ (draft.toolPolicy.sensitivePathDenylist ?? []).join(",")
479
+ );
480
+ if (value !== undefined) {
481
+ const denylist = value
482
+ .split(",")
483
+ .map((item) => item.trim())
484
+ .filter(Boolean);
485
+ draft = { ...draft, toolPolicy: { ...draft.toolPolicy, sensitivePathDenylist: denylist } };
486
+ }
487
+ continue;
488
+ }
489
+ if (action.startsWith("Cost cap/session:")) {
490
+ const value = await ctx.ui.input("Cost cap per session (blank clears)", String(draft.maxEstimatedCostPerSession ?? ""));
491
+ if (value === undefined) continue;
492
+ const trimmed = value.trim();
493
+ if (trimmed.length === 0) {
494
+ const copy = { ...draft };
495
+ delete copy.maxEstimatedCostPerSession;
496
+ draft = copy;
497
+ } else {
498
+ const parsed = Number(trimmed);
499
+ if (Number.isFinite(parsed) && parsed >= 0) {
500
+ draft = { ...draft, maxEstimatedCostPerSession: parsed };
501
+ } else {
502
+ ctx.ui.notify("Invalid cost cap value", "warning");
503
+ }
504
+ }
505
+ continue;
506
+ }
507
+ if (action.startsWith("Save scope:")) {
508
+ const selected = await ctx.ui.select("Save scope", ["project", "global"]);
509
+ if (selected === "project" || selected === "global") scope = selected;
510
+ continue;
511
+ }
512
+ }
513
+ }
514
+
515
+ async function pickModel(ctx: ExtensionContext): Promise<string | undefined> {
516
+ const available = ctx.modelRegistry.getAvailable();
517
+ if (available.length === 0) {
518
+ ctx.ui.notify("No available models found", "warning");
519
+ return undefined;
520
+ }
521
+
522
+ const sorted = [...available].sort((a, b) => {
523
+ const left = `${a.provider}/${a.id}`.toLowerCase();
524
+ const right = `${b.provider}/${b.id}`.toLowerCase();
525
+ return left.localeCompare(right);
526
+ });
527
+
528
+ const options = sorted.slice(0, 200).map((model) => `${model.provider}/${model.id}${model.name ? ` — ${model.name}` : ""}`);
529
+ const selected = await ctx.ui.select("Choose Sage model", options);
530
+ if (!selected) return undefined;
531
+ return selected.split(" — ")[0];
532
+ }
533
+
534
+ function resolveModelSpec(
535
+ configuredModel: string,
536
+ availableModels: ModelLike[],
537
+ currentModel: ModelLike | undefined
538
+ ): { modelArg: string | undefined; reason: string } {
539
+ if (availableModels.length === 0) {
540
+ return { modelArg: undefined, reason: "no available models" };
541
+ }
542
+
543
+ const exactConfigured = availableModels.find((model) => `${model.provider}/${model.id}` === configuredModel);
544
+ if (exactConfigured) {
545
+ return { modelArg: `${exactConfigured.provider}/${exactConfigured.id}`, reason: "configured exact match" };
546
+ }
547
+
548
+ const fuzzyConfigured = configuredModel.trim().toLowerCase();
549
+ if (fuzzyConfigured) {
550
+ const fuzzyMatch = availableModels.find((model) => {
551
+ const composite = `${model.provider}/${model.id}`.toLowerCase();
552
+ const name = model.name?.toLowerCase() ?? "";
553
+ return composite.includes(fuzzyConfigured) || name.includes(fuzzyConfigured);
554
+ });
555
+ if (fuzzyMatch) {
556
+ return { modelArg: `${fuzzyMatch.provider}/${fuzzyMatch.id}`, reason: "configured fuzzy match" };
557
+ }
558
+ }
559
+
560
+ const preferredOpus = availableModels.find((model) => {
561
+ const id = model.id.toLowerCase();
562
+ const name = (model.name ?? "").toLowerCase();
563
+ return model.provider === "anthropic" && (id.includes("opus") || name.includes("opus"));
564
+ });
565
+
566
+ if (preferredOpus) {
567
+ return { modelArg: `${preferredOpus.provider}/${preferredOpus.id}`, reason: "anthropic opus fallback" };
568
+ }
569
+
570
+ if (currentModel) {
571
+ const present = availableModels.find(
572
+ (model) => model.provider === currentModel.provider && model.id === currentModel.id
573
+ );
574
+ if (present) {
575
+ return { modelArg: `${present.provider}/${present.id}`, reason: "current model fallback" };
576
+ }
577
+ }
578
+
579
+ const first = availableModels.at(0);
580
+ if (!first) return { modelArg: undefined, reason: "no available models" };
581
+ return { modelArg: `${first.provider}/${first.id}`, reason: "first available fallback" };
582
+ }
583
+
584
+ function onOff(value: boolean): string {
585
+ return value ? "on" : "off";
586
+ }
587
+
588
+ async function setNumberSetting(
589
+ ctx: ExtensionContext,
590
+ settings: SageSettings,
591
+ key: "timeoutMs" | "maxCallsPerTurn" | "maxCallsPerSession" | "cooldownTurnsBetweenAutoCalls",
592
+ title: string,
593
+ min: number
594
+ ): Promise<SageSettings> {
595
+ const current = String(settings[key]);
596
+ const value = await ctx.ui.input(title, current);
597
+ if (value === undefined) return settings;
598
+
599
+ const parsed = Number(value.trim());
600
+ if (Number.isFinite(parsed) === false) {
601
+ ctx.ui.notify("Invalid numeric value", "warning");
602
+ return settings;
603
+ }
604
+
605
+ const normalized = Math.max(min, Math.floor(parsed));
606
+ return { ...settings, [key]: normalized };
607
+ }
608
+
609
+ async function setToolPolicyNumberSetting(
610
+ ctx: ExtensionContext,
611
+ settings: SageSettings,
612
+ key: "maxToolCalls" | "maxFilesRead" | "maxBytesPerFile" | "maxTotalBytesRead",
613
+ title: string,
614
+ min: number
615
+ ): Promise<SageSettings> {
616
+ const current = String(settings.toolPolicy[key] ?? "");
617
+ const value = await ctx.ui.input(title, current);
618
+ if (value === undefined) return settings;
619
+
620
+ const parsed = Number(value.trim());
621
+ if (Number.isFinite(parsed) === false) {
622
+ ctx.ui.notify("Invalid numeric value", "warning");
623
+ return settings;
624
+ }
625
+
626
+ const normalized = Math.max(min, Math.floor(parsed));
627
+ return {
628
+ ...settings,
629
+ toolPolicy: {
630
+ ...settings.toolPolicy,
631
+ [key]: normalized
632
+ }
633
+ };
634
+ }
635
+
636
+ async function runSettingsTestCall(ctx: ExtensionContext, settings: SageSettings): Promise<void> {
637
+ const modelSelection = resolveModelSpec(settings.model, ctx.modelRegistry.getAvailable(), ctx.model);
638
+ const resolvedModel = modelSelection.modelArg;
639
+ if (!resolvedModel) {
640
+ ctx.ui.notify("Cannot test Sage: no available model", "warning");
641
+ return;
642
+ }
643
+
644
+ try {
645
+ const result = await runSageSingleShot({
646
+ cwd: ctx.cwd,
647
+ model: resolvedModel,
648
+ reasoningLevel: settings.reasoningLevel,
649
+ timeoutMs: Math.min(settings.timeoutMs, 45_000),
650
+ question: "Reply with 'Sage test OK' and one short sentence.",
651
+ toolPolicy: settings.toolPolicy
652
+ });
653
+
654
+ ctx.ui.notify(`Sage test succeeded (${result.latencyMs}ms)`, "info");
655
+ } catch (error) {
656
+ const message = error instanceof Error ? error.message : "Unknown test failure";
657
+ ctx.ui.notify(`Sage test failed: ${message}`, "error");
658
+ }
659
+ }