pi-sage 0.2.4 → 0.2.5

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.
@@ -39,6 +39,7 @@ const ROLE_HINT_ENV_KEYS = ["TASKPLANE_ROLE", "TASKPLANE_AGENT_ROLE", "PI_AGENT_
39
39
 
40
40
  const REASONING_LEVELS: ReasoningLevel[] = ["minimal", "low", "medium", "high", "xhigh"];
41
41
  const TOOL_PROFILES: ToolProfile[] = ["none", "read-only-lite", "git-review-readonly", "custom-read-only"];
42
+ const MODEL_INHERIT_VALUE = "inherit";
42
43
 
43
44
  const SAGE_GUIDANCE = [
44
45
  "You may call `sage_consult` for high-complexity reasoning, ambiguous debugging, risky architectural decisions, or explicit second-opinion requests.",
@@ -374,12 +375,15 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
374
375
  const action = await ctx.ui.select("Sage settings", [
375
376
  `Enabled: ${onOff(draft.enabled)}`,
376
377
  `Autonomous mode: ${onOff(draft.autonomousEnabled)}`,
378
+ `Explicit requests bypass soft limits: ${onOff(draft.explicitRequestAlwaysAllowed)}`,
377
379
  `Model: ${draft.model}`,
378
380
  `Reasoning level: ${draft.reasoningLevel}`,
379
381
  `Timeout ms: ${draft.timeoutMs}`,
380
382
  `Max calls/turn: ${draft.maxCallsPerTurn}`,
381
383
  `Max calls/session: ${draft.maxCallsPerSession}`,
382
384
  `Cooldown turns: ${draft.cooldownTurnsBetweenAutoCalls}`,
385
+ `Max question chars: ${draft.maxQuestionChars}`,
386
+ `Max context chars: ${draft.maxContextChars}`,
383
387
  `Tool profile: ${draft.toolPolicy.profile}`,
384
388
  `Custom tools: ${(draft.toolPolicy.customAllowedTools ?? []).join(",") || "(none)"}`,
385
389
  `Max tool calls: ${draft.toolPolicy.maxToolCalls ?? 10}`,
@@ -419,8 +423,12 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
419
423
  draft = { ...draft, autonomousEnabled: !draft.autonomousEnabled };
420
424
  continue;
421
425
  }
426
+ if (action.startsWith("Explicit requests bypass soft limits:")) {
427
+ draft = { ...draft, explicitRequestAlwaysAllowed: !draft.explicitRequestAlwaysAllowed };
428
+ continue;
429
+ }
422
430
  if (action.startsWith("Model:")) {
423
- const selected = await pickModel(ctx);
431
+ const selected = await pickModel(ctx, draft.model);
424
432
  if (selected) draft = { ...draft, model: selected };
425
433
  continue;
426
434
  }
@@ -447,6 +455,14 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
447
455
  draft = await setNumberSetting(ctx, draft, "cooldownTurnsBetweenAutoCalls", "Cooldown turns", 0);
448
456
  continue;
449
457
  }
458
+ if (action.startsWith("Max question chars:")) {
459
+ draft = await setNumberSetting(ctx, draft, "maxQuestionChars", "Max question chars", 256);
460
+ continue;
461
+ }
462
+ if (action.startsWith("Max context chars:")) {
463
+ draft = await setNumberSetting(ctx, draft, "maxContextChars", "Max context chars", 512);
464
+ continue;
465
+ }
450
466
  if (action.startsWith("Tool profile:")) {
451
467
  const selected = await ctx.ui.select("Tool profile", [...TOOL_PROFILES]);
452
468
  if (selected && TOOL_PROFILES.includes(selected as ToolProfile)) {
@@ -521,23 +537,63 @@ async function runSettingsWizard(ctx: ExtensionCommandContext): Promise<void> {
521
537
  }
522
538
  }
523
539
 
524
- async function pickModel(ctx: ExtensionContext): Promise<string | undefined> {
540
+ async function pickModel(ctx: ExtensionContext, currentModel: string): Promise<string | undefined> {
525
541
  const available = ctx.modelRegistry.getAvailable();
526
542
  if (available.length === 0) {
527
543
  ctx.ui.notify("No available models found", "warning");
528
544
  return undefined;
529
545
  }
530
546
 
531
- const sorted = [...available].sort((a, b) => {
532
- const left = `${a.provider}/${a.id}`.toLowerCase();
533
- const right = `${b.provider}/${b.id}`.toLowerCase();
534
- return left.localeCompare(right);
535
- });
547
+ const currentLower = currentModel.trim().toLowerCase();
548
+ const providers = [...new Set(available.map((model) => model.provider))].sort((a, b) => a.localeCompare(b));
549
+
550
+ while (true) {
551
+ const providerOptions = [
552
+ `${MODEL_INHERIT_VALUE} (use current session model)`,
553
+ ...providers.map((provider) => {
554
+ const count = available.filter((model) => model.provider === provider).length;
555
+ return `${provider} (${count})`;
556
+ })
557
+ ];
558
+
559
+ const providerChoice = await ctx.ui.select("Choose Sage model provider", providerOptions);
560
+ if (!providerChoice) return undefined;
561
+
562
+ if (providerChoice.startsWith(MODEL_INHERIT_VALUE)) {
563
+ return MODEL_INHERIT_VALUE;
564
+ }
565
+
566
+ const provider = providerChoice.replace(/\s*\(\d+\)$/, "");
567
+ const providerModels = available
568
+ .filter((model) => model.provider === provider)
569
+ .sort((a, b) => {
570
+ const aComposite = `${a.provider}/${a.id}`.toLowerCase();
571
+ const bComposite = `${b.provider}/${b.id}`.toLowerCase();
572
+ const aCurrent = aComposite === currentLower;
573
+ const bCurrent = bComposite === currentLower;
574
+ if (aCurrent && !bCurrent) return -1;
575
+ if (!aCurrent && bCurrent) return 1;
576
+ return a.id.localeCompare(b.id);
577
+ });
536
578
 
537
- const options = sorted.slice(0, 200).map((model) => `${model.provider}/${model.id}${model.name ? ` — ${model.name}` : ""}`);
538
- const selected = await ctx.ui.select("Choose Sage model", options);
539
- if (!selected) return undefined;
540
- return selected.split(" ")[0];
579
+ const modelOptionMap = new Map<string, string>();
580
+ const modelOptions = [" Back to providers"];
581
+
582
+ for (const model of providerModels) {
583
+ const composite = `${model.provider}/${model.id}`;
584
+ const isCurrent = composite.toLowerCase() === currentLower;
585
+ const option = `${model.id}${isCurrent ? " ✓ current" : ""}`;
586
+ modelOptions.push(option);
587
+ modelOptionMap.set(option, composite);
588
+ }
589
+
590
+ const modelChoice = await ctx.ui.select(`Choose Sage model (${provider})`, modelOptions);
591
+ if (!modelChoice) return undefined;
592
+ if (modelChoice === "← Back to providers") continue;
593
+
594
+ const resolved = modelOptionMap.get(modelChoice);
595
+ if (resolved) return resolved;
596
+ }
541
597
  }
542
598
 
543
599
  function resolveModelSpec(
@@ -549,6 +605,22 @@ function resolveModelSpec(
549
605
  return { modelArg: undefined, reason: "no available models" };
550
606
  }
551
607
 
608
+ if (configuredModel.trim().toLowerCase() === MODEL_INHERIT_VALUE) {
609
+ if (currentModel) {
610
+ const present = availableModels.find(
611
+ (model) => model.provider === currentModel.provider && model.id === currentModel.id
612
+ );
613
+ if (present) {
614
+ return { modelArg: `${present.provider}/${present.id}`, reason: "inherit current model" };
615
+ }
616
+ }
617
+
618
+ const firstAvailable = availableModels.at(0);
619
+ return firstAvailable
620
+ ? { modelArg: `${firstAvailable.provider}/${firstAvailable.id}`, reason: "inherit fallback first available" }
621
+ : { modelArg: undefined, reason: "no available models" };
622
+ }
623
+
552
624
  const exactConfigured = availableModels.find((model) => `${model.provider}/${model.id}` === configuredModel);
553
625
  if (exactConfigured) {
554
626
  return { modelArg: `${exactConfigured.provider}/${exactConfigured.id}`, reason: "configured exact match" };
@@ -597,7 +669,13 @@ function onOff(value: boolean): string {
597
669
  async function setNumberSetting(
598
670
  ctx: ExtensionContext,
599
671
  settings: SageSettings,
600
- key: "timeoutMs" | "maxCallsPerTurn" | "maxCallsPerSession" | "cooldownTurnsBetweenAutoCalls",
672
+ key:
673
+ | "timeoutMs"
674
+ | "maxCallsPerTurn"
675
+ | "maxCallsPerSession"
676
+ | "cooldownTurnsBetweenAutoCalls"
677
+ | "maxQuestionChars"
678
+ | "maxContextChars",
601
679
  title: string,
602
680
  min: number
603
681
  ): Promise<SageSettings> {
@@ -46,18 +46,18 @@ export const DEFAULT_SAGE_SETTINGS: SageSettings = {
46
46
  autonomousEnabled: true,
47
47
  explicitRequestAlwaysAllowed: true,
48
48
  invocationScope: "interactive-primary-only",
49
- model: "anthropic/claude-opus-4-6",
49
+ model: "inherit",
50
50
  reasoningLevel: "high",
51
- timeoutMs: 120000,
51
+ timeoutMs: 720000,
52
52
  maxCallsPerTurn: 1,
53
- maxCallsPerSession: 6,
53
+ maxCallsPerSession: 100000,
54
54
  cooldownTurnsBetweenAutoCalls: 1,
55
- maxQuestionChars: 6000,
56
- maxContextChars: 20000,
55
+ maxQuestionChars: 12000,
56
+ maxContextChars: 64000,
57
57
  toolPolicy: {
58
- profile: "read-only-lite",
59
- maxToolCalls: 10,
60
- maxFilesRead: 8,
58
+ profile: "git-review-readonly",
59
+ maxToolCalls: 250,
60
+ maxFilesRead: 100,
61
61
  maxBytesPerFile: 200 * 1024,
62
62
  maxTotalBytesRead: 1024 * 1024,
63
63
  sensitivePathDenylist: DEFAULT_DENYLIST
@@ -82,6 +82,14 @@ function normalizeNumber(value: unknown): number | undefined {
82
82
  return value;
83
83
  }
84
84
 
85
+ function normalizeModelSetting(value: unknown): string | undefined {
86
+ if (typeof value !== "string") return undefined;
87
+ const trimmed = value.trim();
88
+ if (!trimmed) return undefined;
89
+ if (trimmed.toLowerCase() === "inherit-current") return "inherit";
90
+ return trimmed;
91
+ }
92
+
85
93
  export function mergeSettings(override: Partial<SageSettings> | undefined): SageSettings {
86
94
  const merged: SageSettings = {
87
95
  ...DEFAULT_SAGE_SETTINGS,
@@ -92,6 +100,8 @@ export function mergeSettings(override: Partial<SageSettings> | undefined): Sage
92
100
  }
93
101
  };
94
102
 
103
+ merged.model = normalizeModelSetting(merged.model) ?? DEFAULT_SAGE_SETTINGS.model;
104
+
95
105
  merged.timeoutMs = Math.max(1000, Math.floor(merged.timeoutMs));
96
106
  merged.maxCallsPerTurn = Math.max(1, Math.floor(merged.maxCallsPerTurn));
97
107
  merged.maxCallsPerSession = Math.max(1, Math.floor(merged.maxCallsPerSession));
@@ -145,7 +155,7 @@ function parseSettingsRaw(content: string): Partial<SageSettings> | undefined {
145
155
  typeof parsed.explicitRequestAlwaysAllowed === "boolean" ? parsed.explicitRequestAlwaysAllowed : undefined,
146
156
  invocationScope:
147
157
  parsed.invocationScope === "interactive-primary-only" ? "interactive-primary-only" : undefined,
148
- model: typeof parsed.model === "string" ? parsed.model : undefined,
158
+ model: normalizeModelSetting(parsed.model),
149
159
  reasoningLevel:
150
160
  parsed.reasoningLevel === "minimal" ||
151
161
  parsed.reasoningLevel === "low" ||
@@ -194,7 +204,15 @@ export function loadSettings(cwd: string): LoadedSettings {
194
204
 
195
205
  export function saveSettings(path: string, settings: SageSettings): void {
196
206
  mkdirSync(dirname(path), { recursive: true });
197
- writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
207
+
208
+ const persisted: Omit<SageSettings, "invocationScope"> & { invocationScope?: SageSettings["invocationScope"] } = {
209
+ ...settings
210
+ };
211
+
212
+ // invocationScope is currently fixed by policy and not user-configurable in UI.
213
+ delete persisted.invocationScope;
214
+
215
+ writeFileSync(path, `${JSON.stringify(persisted, null, 2)}\n`, "utf8");
198
216
  }
199
217
 
200
218
  export function getSettingsPathForScope(cwd: string, scope: SettingsScope): string {
package/README.md CHANGED
@@ -44,8 +44,8 @@ Then in Pi run:
44
44
 
45
45
  ## Tool profiles
46
46
 
47
- - `read-only-lite` (default): `ls, glob, grep, read`
48
- - `git-review-readonly`: adds restricted `bash` for allowlisted **read-only git** commands
47
+ - `read-only-lite`: `ls, glob, grep, read`
48
+ - `git-review-readonly` (default): adds restricted `bash` for allowlisted **read-only git** commands
49
49
  - `none`
50
50
  - `custom-read-only`
51
51
 
package/docs/SAGE_SPEC.md CHANGED
@@ -72,7 +72,7 @@ This spec targets a **final architecture** (not MVP): Sage is implemented as a *
72
72
  - `pi --mode json -p --no-session --model <configured-model> ...`
73
73
  - Provides dedicated Sage system prompt.
74
74
  - Enforces **single-shot execution** per call (one request → one response).
75
- - Enforces advisory tool policy (default `read-only-lite`: `ls,glob,grep,read`).
75
+ - Enforces advisory tool policy (default `git-review-readonly`: `ls,glob,grep,read,bash` with strict allowlisted read-only git commands).
76
76
  - Supports stricter `none` mode, optional `git-review-readonly` mode, and constrained custom read-only lists.
77
77
  - Explicitly disables `sage_consult` inside Sage subprocess context to prevent subagent recursion.
78
78
 
@@ -188,15 +188,15 @@ Primary agent guidance to append:
188
188
  ## 8.2 Soft limits (defaults)
189
189
  Soft limits are policy/budget controls and are bypassable for explicit user requests (`force=true`).
190
190
  - `maxCallsPerTurn: 1`
191
- - `maxCallsPerSession: 6`
191
+ - `maxCallsPerSession: 100000`
192
192
  - `cooldownTurnsBetweenAutoCalls: 1`
193
- - `maxQuestionChars: 6000`
194
- - `maxContextChars: 20000`
193
+ - `maxQuestionChars: 12000`
194
+ - `maxContextChars: 64000`
195
195
 
196
196
  ## 8.3 Hard safety limits (non-bypassable)
197
197
  - Disallowed caller context (not top-level interactive primary session).
198
198
  - Model unavailable or no API key.
199
- - Timeout exceeded (`timeoutMs`, default `120000`).
199
+ - Timeout exceeded (`timeoutMs`, default `720000`).
200
200
  - Absolute cost cap exceeded (`maxEstimatedCostPerSession`, optional).
201
201
 
202
202
  ## 8.4 Explicit user request behavior
@@ -294,11 +294,11 @@ Interactive command to configure Sage runtime behavior.
294
294
  8. **Cooldown between autonomous calls**
295
295
  9. **Tool profile for Sage subagent**:
296
296
  - none (strict advisor mode)
297
- - read-only-lite (`ls,glob,grep,read`) **default**
298
- - git-review-readonly (`ls,glob,grep,read,bash`) with strict allowlisted git read commands only
297
+ - read-only-lite (`ls,glob,grep,read`)
298
+ - git-review-readonly (`ls,glob,grep,read,bash`) with strict allowlisted git read commands only **default**
299
299
  - custom read-only list (must exclude mutating/execution tools)
300
- 10. **Per-call max tool calls** (default: 10)
301
- 11. **Per-call max files read** (default: 8)
300
+ 10. **Per-call max tool calls** (default: 250)
301
+ 11. **Per-call max files read** (default: 100)
302
302
  12. **Per-file max bytes** (default: 200KB)
303
303
  13. **Per-call max total bytes read** (default: 1MB)
304
304
  14. **Sensitive path denylist** (default on; configurable additions)
@@ -320,7 +320,7 @@ Interactive command to configure Sage runtime behavior.
320
320
  4. Never pass secrets intentionally unless present in existing prompt/context.
321
321
 
322
322
  ### 11.1 Tool-access policy (advisor model)
323
- 1. Default Sage tool profile is `read-only-lite`: `ls,glob,grep,read`.
323
+ 1. Default Sage tool profile is `git-review-readonly`: `ls,glob,grep,read,bash` with strict read-only git command allowlist.
324
324
  2. Allow `none` profile for stricter environments.
325
325
  3. Custom profile must be read-only; mutating/execution tools are disallowed.
326
326
  4. Explicitly disallow: `edit`, `write`, git-mutating tools, orchestration-control tools, and network/http tools.
@@ -330,8 +330,8 @@ Interactive command to configure Sage runtime behavior.
330
330
  1. Restrict reads to workspace/project roots.
331
331
  2. Denylist sensitive paths by default (e.g., `.env*`, `*.pem`, `*.key`, `id_rsa*`, credential stores).
332
332
  3. Enforce caps per Sage call:
333
- - max tool calls: `10`
334
- - max files read: `8`
333
+ - max tool calls: `250`
334
+ - max files read: `100`
335
335
  - max bytes per file: `200KB`
336
336
  - max total bytes read: `1MB`
337
337
  4. Cap `grep`/`glob` result counts to avoid context explosion.
@@ -379,8 +379,8 @@ On tool-policy/data-policy block:
379
379
  4. User phrase “get a second opinion” in an eligible interactive primary session causes at least one Sage consultation in same task flow.
380
380
  5. Explicit user-requested Sage consultation can bypass soft limits, but still respects hard safety limits and caller-scope restrictions.
381
381
  6. No `/sage` command exists.
382
- 7. `/sage-settings` lists available models interactively and persists selected model.
383
- 8. Default Sage tool profile is `read-only-lite` (`ls,glob,grep,read`), with `none`, `git-review-readonly`, and constrained custom read-only options available.
382
+ 7. `/sage-settings` supports model configuration interactively (including `inherit`) and persists selected value.
383
+ 8. Default Sage tool profile is `git-review-readonly` (`ls,glob,grep,read,bash` with strict allowlisted read-only git commands), with `none`, `read-only-lite`, and constrained custom read-only options available.
384
384
  9. Sage tool profile blocks mutating/execution/network/orchestration tools (including `edit`, `write`, and `sage_consult`), except allowlisted git-read bash commands in `git-review-readonly`.
385
385
  10. Sage enforces per-call guardrails (max tool calls/files/bytes and capped `grep`/`glob` output).
386
386
  11. Sage calls obey hard safety limits (caller context, model/auth availability, timeout, optional absolute cost cap).
@@ -399,7 +399,7 @@ On tool-policy/data-policy block:
399
399
  5. Start with model interpretation for explicit-request detection; add phrase-detection hook only if testing reveals reliability issues.
400
400
  6. Sage subagents cannot invoke other Sage subagents (no recursion).
401
401
  7. Sage is advisory-only and should not perform implementation actions.
402
- 8. Default Sage tool profile is `read-only-lite` (`ls,glob,grep,read`) with strict guardrails; optional `git-review-readonly` exists for code-review workflows.
402
+ 8. Default Sage tool profile is `git-review-readonly` (`ls,glob,grep,read,bash`) with strict guardrails; `bash` is restricted to allowlisted read-only git commands.
403
403
 
404
404
  ---
405
405
 
@@ -74,7 +74,7 @@ Every implementation must preserve these behaviors:
74
74
 
75
75
  ## 7) Security and Access Controls
76
76
 
77
- 1. Enforce advisory tool policy by default (`read-only-lite`: `ls,glob,grep,read`).
77
+ 1. Enforce advisory tool policy by default (`git-review-readonly`: `ls,glob,grep,read,bash` with strict read-only git command allowlist).
78
78
  2. Disallow mutating/execution/network/orchestration tools in Sage.
79
79
  3. Restrict filesystem reads to workspace/project roots.
80
80
  4. Apply sensitive-path denylist by default (`.env*`, `*.pem`, `*.key`, etc.).
@@ -125,7 +125,7 @@ Required E2E checks:
125
125
  - [ ] no recursion
126
126
 
127
127
  ### Tool/data policy
128
- - [ ] default profile is `read-only-lite`
128
+ - [ ] default profile is `git-review-readonly`
129
129
  - [ ] disallowed tools blocked (`edit`, `write`, `bash`, `sage_consult`, etc.)
130
130
  - [ ] denylisted paths blocked
131
131
  - [ ] volume caps enforced
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sage",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Interactive-only advisory Sage extension for Pi",