pi-sage 0.2.13 → 0.2.15

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,50 @@
1
+ import type { CallerContext } from "./types.js";
2
+
3
+ export type InputSource = "interactive" | "rpc" | "extension";
4
+ export type InteractiveOrRpcSource = "interactive" | "rpc";
5
+
6
+ export function isKnownInputSource(value: string): value is InputSource {
7
+ return value === "interactive" || value === "rpc" || value === "extension";
8
+ }
9
+
10
+ export function updateLastInteractiveOrRpcSource(
11
+ previous: InteractiveOrRpcSource | undefined,
12
+ source: string
13
+ ): InteractiveOrRpcSource | undefined {
14
+ if (source === "interactive" || source === "rpc") {
15
+ return source;
16
+ }
17
+ return previous;
18
+ }
19
+
20
+ export function buildCallerContextFromSignals(input: {
21
+ lastInteractiveOrRpcSource: InteractiveOrRpcSource | undefined;
22
+ unknownSourceSeen: boolean;
23
+ hasUI: boolean;
24
+ roleHint?: string;
25
+ isCI?: boolean;
26
+ isSubagent?: boolean;
27
+ }): CallerContext | null {
28
+ const { lastInteractiveOrRpcSource, unknownSourceSeen, hasUI, roleHint, isCI, isSubagent } = input;
29
+ if (unknownSourceSeen) return null;
30
+ if (!lastInteractiveOrRpcSource) return null;
31
+
32
+ const normalizedRole = roleHint?.trim().toLowerCase();
33
+ const isRpcSource = lastInteractiveOrRpcSource === "rpc";
34
+ const interactive = hasUI && lastInteractiveOrRpcSource === "interactive";
35
+ const isInteractiveSupervisor = interactive && normalizedRole === "supervisor";
36
+
37
+ return {
38
+ session: {
39
+ interactive
40
+ },
41
+ agent: {
42
+ role: normalizedRole ?? "primary",
43
+ isSubagent: Boolean(isSubagent),
44
+ isRpcOrchestrated: isRpcSource || Boolean(normalizedRole && normalizedRole !== "primary" && !isInteractiveSupervisor)
45
+ },
46
+ runtime: {
47
+ mode: isCI ? "ci" : hasUI ? (isRpcSource ? "rpc" : "interactive") : "non-interactive"
48
+ }
49
+ };
50
+ }
@@ -7,14 +7,26 @@ import {
7
7
  isHardCostCapExceeded,
8
8
  makeBlockedResult
9
9
  } from "./policy.js";
10
- import { isSageRunnerPolicyError, runSageSingleShot } from "./runner.js";
10
+ import {
11
+ buildCallerContextFromSignals,
12
+ isKnownInputSource,
13
+ updateLastInteractiveOrRpcSource,
14
+ type InteractiveOrRpcSource
15
+ } from "./caller-context.js";
16
+ import {
17
+ isSageRunnerPolicyError,
18
+ runSageSingleShot,
19
+ type SageRunnerInput,
20
+ type SageRunnerOutput
21
+ } from "./runner.js";
11
22
  import {
12
23
  getSettingsPathForScope,
13
24
  loadSettings,
14
25
  mergeSettings,
15
26
  saveSettings,
16
27
  type SageSettings,
17
- type SettingsScope
28
+ type SettingsScope,
29
+ type ToolPolicySettings
18
30
  } from "./settings.js";
19
31
  import { checkVolumeCaps, getDisallowedCustomTools, resolveToolPolicy } from "./tool-policy.js";
20
32
  import type {
@@ -28,8 +40,6 @@ import type {
28
40
  ToolProfile
29
41
  } from "./types.js";
30
42
 
31
- type InputSource = "interactive" | "rpc" | "extension";
32
-
33
43
  type ModelLike = {
34
44
  provider: string;
35
45
  id: string;
@@ -68,12 +78,22 @@ export default function registerSageExtension(pi: ExtensionAPI): void {
68
78
  sessionCostTotal: 0
69
79
  };
70
80
 
71
- let lastInputSource: InputSource | undefined;
81
+ let lastInteractiveOrRpcSource: InteractiveOrRpcSource | undefined;
82
+ let unknownInputSourceSeen = false;
72
83
 
73
84
  pi.on("input", (event) => {
74
- if (event.source === "interactive" || event.source === "rpc" || event.source === "extension") {
75
- lastInputSource = event.source;
85
+ const source = String(event.source ?? "");
86
+
87
+ if (!isKnownInputSource(source)) {
88
+ unknownInputSourceSeen = true;
89
+ return { action: "continue" };
90
+ }
91
+
92
+ lastInteractiveOrRpcSource = updateLastInteractiveOrRpcSource(lastInteractiveOrRpcSource, source);
93
+ if (source === "interactive" || source === "rpc") {
94
+ unknownInputSourceSeen = false;
76
95
  }
96
+
77
97
  return { action: "continue" };
78
98
  });
79
99
 
@@ -160,7 +180,7 @@ export default function registerSageExtension(pi: ExtensionAPI): void {
160
180
  });
161
181
  }
162
182
 
163
- const callerContext = buildCallerContext(lastInputSource, ctx.hasUI);
183
+ const callerContext = buildCallerContext(lastInteractiveOrRpcSource, unknownInputSourceSeen, ctx.hasUI);
164
184
  const eligibility = isEligibleCaller(callerContext);
165
185
  if (!eligibility.ok) {
166
186
  return makeBlockedResult({
@@ -232,10 +252,8 @@ export default function registerSageExtension(pi: ExtensionAPI): void {
232
252
  });
233
253
  }
234
254
 
235
- const toolPolicy = resolveToolPolicy(settings.toolPolicy);
236
-
237
255
  try {
238
- const runnerResult = await runSageSingleShot({
256
+ const runnerInput: Omit<SageRunnerInput, "toolPolicy"> = {
239
257
  cwd: ctx.cwd,
240
258
  model: resolvedModel,
241
259
  reasoningLevel: settings.reasoningLevel,
@@ -246,11 +264,13 @@ export default function registerSageExtension(pi: ExtensionAPI): void {
246
264
  evidence: input.evidence,
247
265
  objective: input.objective,
248
266
  urgency: input.urgency,
249
- toolPolicy: settings.toolPolicy,
250
267
  signal
251
- });
268
+ };
252
269
 
253
- const volume = checkVolumeCaps(runnerResult.toolUsage, toolPolicy);
270
+ const invocation = await runSageWithFallback(runnerInput, settings.toolPolicy);
271
+ const toolPolicy = resolveToolPolicy(invocation.toolPolicyUsed);
272
+
273
+ const volume = checkVolumeCaps(invocation.result.toolUsage, toolPolicy);
254
274
  if (!volume.ok) {
255
275
  return makeBlockedResult({
256
276
  mode,
@@ -266,21 +286,25 @@ export default function registerSageExtension(pi: ExtensionAPI): void {
266
286
  budgetState.callsThisTurn += 1;
267
287
  budgetState.sessionCalls += 1;
268
288
  if (mode === "autonomous") budgetState.lastAutoTurn = budgetState.currentTurn;
269
- if (runnerResult.usage?.costTotal) budgetState.sessionCostTotal += runnerResult.usage.costTotal;
289
+ if (invocation.result.usage?.costTotal) budgetState.sessionCostTotal += invocation.result.usage.costTotal;
290
+
291
+ const contextReason = invocation.fallbackReason
292
+ ? "eligible; fallback to read-only-lite after blocked bash command"
293
+ : "eligible";
270
294
 
271
295
  return {
272
- content: [{ type: "text", text: runnerResult.text }],
296
+ content: [{ type: "text", text: invocation.result.text }],
273
297
  details: {
274
298
  model: resolvedModel,
275
299
  reasoningLevel: settings.reasoningLevel,
276
- latencyMs: runnerResult.latencyMs,
277
- stopReason: runnerResult.stopReason,
278
- usage: runnerResult.usage,
279
- toolUsage: runnerResult.toolUsage,
300
+ latencyMs: invocation.result.latencyMs,
301
+ stopReason: invocation.result.stopReason,
302
+ usage: invocation.result.usage,
303
+ toolUsage: invocation.result.toolUsage,
280
304
  policy: {
281
305
  mode,
282
306
  allowedByContext: true,
283
- contextReason: "eligible",
307
+ contextReason,
284
308
  allowedByBudget: true,
285
309
  budgetReason: softBudget.reason
286
310
  }
@@ -353,28 +377,45 @@ function formatRunnerPolicyReason(blockCode: BlockCode, reason: string): string
353
377
  return reason;
354
378
  }
355
379
 
356
- function buildCallerContext(lastInputSource: InputSource | undefined, hasUI: boolean): CallerContext | null {
357
- if (!lastInputSource) return null;
358
-
359
- const roleHint = getRoleHint();
360
- const isRpcSource = lastInputSource === "rpc";
361
- const isSubagent = process.env.PI_SAGE_SUBAGENT === "1";
362
- const interactive = hasUI && lastInputSource === "interactive";
363
- const isInteractiveSupervisor = interactive && roleHint === "supervisor";
380
+ async function runSageWithFallback(
381
+ runnerInput: Omit<SageRunnerInput, "toolPolicy">,
382
+ toolPolicy: ToolPolicySettings
383
+ ): Promise<{ result: SageRunnerOutput; toolPolicyUsed: ToolPolicySettings; fallbackReason?: string }> {
384
+ try {
385
+ const result = await runSageSingleShot({ ...runnerInput, toolPolicy });
386
+ return { result, toolPolicyUsed: toolPolicy };
387
+ } catch (error) {
388
+ if (
389
+ isSageRunnerPolicyError(error) &&
390
+ error.blockCode === "tool-disallowed" &&
391
+ toolPolicy.profile === "git-review-readonly"
392
+ ) {
393
+ const fallbackPolicy: ToolPolicySettings = {
394
+ ...toolPolicy,
395
+ profile: "read-only-lite"
396
+ };
397
+
398
+ const result = await runSageSingleShot({ ...runnerInput, toolPolicy: fallbackPolicy });
399
+ return { result, toolPolicyUsed: fallbackPolicy, fallbackReason: error.message };
400
+ }
401
+
402
+ throw error;
403
+ }
404
+ }
364
405
 
365
- return {
366
- session: {
367
- interactive
368
- },
369
- agent: {
370
- role: roleHint ?? "primary",
371
- isSubagent,
372
- isRpcOrchestrated: isRpcSource || Boolean(roleHint && roleHint !== "primary" && !isInteractiveSupervisor)
373
- },
374
- runtime: {
375
- mode: process.env.CI ? "ci" : hasUI ? (isRpcSource ? "rpc" : "interactive") : "non-interactive"
376
- }
377
- };
406
+ function buildCallerContext(
407
+ lastInteractiveOrRpcSource: InteractiveOrRpcSource | undefined,
408
+ unknownInputSourceSeen: boolean,
409
+ hasUI: boolean
410
+ ): CallerContext | null {
411
+ return buildCallerContextFromSignals({
412
+ lastInteractiveOrRpcSource,
413
+ unknownSourceSeen: unknownInputSourceSeen,
414
+ hasUI,
415
+ roleHint: getRoleHint(),
416
+ isCI: Boolean(process.env.CI),
417
+ isSubagent: process.env.PI_SAGE_SUBAGENT === "1"
418
+ });
378
419
  }
379
420
 
380
421
  function getRoleHint(): string | undefined {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sage",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Interactive-only advisory Sage extension for Pi",