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,114 @@
1
+ import type { BlockCode, CallerContext, CallerDecision, SageBudgetState, SageMode, SageToolResult } from "./types.js";
2
+ import type { SageSettings } from "./settings.js";
3
+
4
+ export function isEligibleCaller(ctx: CallerContext | null | undefined): CallerDecision {
5
+ if (!ctx || !ctx.session || !ctx.agent || !ctx.runtime) {
6
+ return { ok: false, blockCode: "unknown-context", reason: "Missing caller context" };
7
+ }
8
+
9
+ if (ctx.session.interactive !== true) {
10
+ return { ok: false, blockCode: "non-interactive", reason: "Sage allowed only in interactive sessions" };
11
+ }
12
+
13
+ if (ctx.runtime.mode === "ci") {
14
+ return { ok: false, blockCode: "ci-mode", reason: "Sage disabled in CI mode" };
15
+ }
16
+
17
+ if (ctx.agent.isRpcOrchestrated === true || ctx.runtime.mode === "rpc") {
18
+ return { ok: false, blockCode: "rpc-role", reason: "Sage disabled for RPC-orchestrated agents" };
19
+ }
20
+
21
+ if (ctx.agent.isSubagent === true) {
22
+ return { ok: false, blockCode: "subagent", reason: "Sage disabled for subagents" };
23
+ }
24
+
25
+ if (ctx.agent.role !== "primary") {
26
+ return { ok: false, blockCode: "ineligible-caller", reason: "Only primary agent may invoke Sage" };
27
+ }
28
+
29
+ return { ok: true, reason: "eligible" };
30
+ }
31
+
32
+ export function isHardCostCapExceeded(settings: SageSettings, budgetState: SageBudgetState): boolean {
33
+ if (settings.maxEstimatedCostPerSession === undefined) return false;
34
+ return budgetState.sessionCostTotal >= settings.maxEstimatedCostPerSession;
35
+ }
36
+
37
+ export function evaluateSoftBudget(input: {
38
+ settings: SageSettings;
39
+ budgetState: SageBudgetState;
40
+ mode: SageMode;
41
+ force: boolean;
42
+ questionLength: number;
43
+ contextLength: number;
44
+ }): { ok: boolean; reason: string } {
45
+ const { settings, budgetState, mode, force, questionLength, contextLength } = input;
46
+
47
+ const forceBypassesSoftLimits = mode === "user-requested" && force === true && settings.explicitRequestAlwaysAllowed;
48
+ if (forceBypassesSoftLimits) {
49
+ return { ok: true, reason: "force=true bypassed soft limits" };
50
+ }
51
+
52
+ if (mode === "autonomous" && settings.autonomousEnabled === false) {
53
+ return { ok: false, reason: "Autonomous Sage calls are disabled" };
54
+ }
55
+
56
+ if (questionLength > settings.maxQuestionChars) {
57
+ return { ok: false, reason: `Question exceeds soft limit (${settings.maxQuestionChars} chars)` };
58
+ }
59
+
60
+ if (contextLength > settings.maxContextChars) {
61
+ return { ok: false, reason: `Context exceeds soft limit (${settings.maxContextChars} chars)` };
62
+ }
63
+
64
+ if (mode === "autonomous") {
65
+ if (budgetState.callsThisTurn >= settings.maxCallsPerTurn) {
66
+ return { ok: false, reason: "Per-turn Sage call soft limit reached" };
67
+ }
68
+
69
+ if (budgetState.sessionCalls >= settings.maxCallsPerSession) {
70
+ return { ok: false, reason: "Per-session Sage call soft limit reached" };
71
+ }
72
+
73
+ if (budgetState.lastAutoTurn !== undefined) {
74
+ const turnsSinceLastAuto = budgetState.currentTurn - budgetState.lastAutoTurn;
75
+ if (turnsSinceLastAuto <= settings.cooldownTurnsBetweenAutoCalls) {
76
+ return {
77
+ ok: false,
78
+ reason: `Autonomous cooldown active (${settings.cooldownTurnsBetweenAutoCalls} turns required)`
79
+ };
80
+ }
81
+ }
82
+ }
83
+
84
+ return { ok: true, reason: "within soft limits" };
85
+ }
86
+
87
+ export function makeBlockedResult(input: {
88
+ mode: SageMode;
89
+ model?: string;
90
+ reasoningLevel?: "minimal" | "low" | "medium" | "high" | "xhigh";
91
+ blockCode: BlockCode;
92
+ reason: string;
93
+ allowedByContext: boolean;
94
+ allowedByBudget: boolean;
95
+ }): SageToolResult {
96
+ return {
97
+ content: [{ type: "text", text: `Sage blocked: ${input.reason}` }],
98
+ details: {
99
+ model: input.model ?? "unavailable",
100
+ reasoningLevel: input.reasoningLevel ?? "minimal",
101
+ latencyMs: 0,
102
+ stopReason: "blocked",
103
+ policy: {
104
+ mode: input.mode,
105
+ allowedByContext: input.allowedByContext,
106
+ contextReason: input.allowedByContext ? "eligible" : input.reason,
107
+ blockCode: input.blockCode,
108
+ allowedByBudget: input.allowedByBudget,
109
+ budgetReason: input.allowedByBudget ? "within limits" : input.reason
110
+ }
111
+ },
112
+ isError: false
113
+ };
114
+ }
@@ -0,0 +1,461 @@
1
+ import { spawn } from "node:child_process";
2
+ import { EOL } from "node:os";
3
+ import { isAbsolute, resolve } from "node:path";
4
+ import { isPathAllowed, resolveToolPolicy, validateBashCommandForProfile } from "./tool-policy.js";
5
+ import type { ToolPolicySettings } from "./settings.js";
6
+ import type {
7
+ BlockCode,
8
+ Objective,
9
+ ReasoningLevel,
10
+ ToolUsage,
11
+ Urgency,
12
+ UsageBreakdown
13
+ } from "./types.js";
14
+
15
+ export interface SageRunnerInput {
16
+ cwd: string;
17
+ model: string;
18
+ reasoningLevel: ReasoningLevel;
19
+ timeoutMs: number;
20
+ question: string;
21
+ context?: string;
22
+ files?: string[];
23
+ evidence?: string[];
24
+ objective?: Objective;
25
+ urgency?: Urgency;
26
+ toolPolicy: ToolPolicySettings;
27
+ signal?: AbortSignal;
28
+ }
29
+
30
+ export interface SageRunnerOutput {
31
+ text: string;
32
+ latencyMs: number;
33
+ stopReason: string;
34
+ usage?: UsageBreakdown;
35
+ toolUsage: ToolUsage;
36
+ }
37
+
38
+ interface JsonEvent {
39
+ type?: string;
40
+ toolName?: string;
41
+ args?: unknown;
42
+ result?: {
43
+ content?: Array<{ type?: string; text?: string }>;
44
+ };
45
+ message?: {
46
+ role?: string;
47
+ content?: Array<{ type?: string; text?: string }>;
48
+ stopReason?: string;
49
+ usage?: {
50
+ input?: number;
51
+ output?: number;
52
+ cacheRead?: number;
53
+ cacheWrite?: number;
54
+ totalTokens?: number;
55
+ cost?: { total?: number };
56
+ };
57
+ };
58
+ }
59
+
60
+ export class SageRunnerPolicyError extends Error {
61
+ constructor(
62
+ readonly blockCode: BlockCode,
63
+ message: string
64
+ ) {
65
+ super(message);
66
+ this.name = "SageRunnerPolicyError";
67
+ }
68
+ }
69
+
70
+ export function isSageRunnerPolicyError(error: unknown): error is SageRunnerPolicyError {
71
+ return error instanceof SageRunnerPolicyError;
72
+ }
73
+
74
+ export async function runSageSingleShot(input: SageRunnerInput): Promise<SageRunnerOutput> {
75
+ const policy = resolveToolPolicy(input.toolPolicy);
76
+ const startedAt = Date.now();
77
+
78
+ const invocation = resolvePiInvocation();
79
+ const args = [...invocation.prefixArgs, ...buildPiArgs(input.model, input.reasoningLevel, policy.cliTools)];
80
+ const prompt = buildSagePrompt(input);
81
+
82
+ const child = spawn(invocation.command, args, {
83
+ cwd: input.cwd,
84
+ env: {
85
+ ...process.env,
86
+ PI_SAGE_SUBAGENT: "1"
87
+ },
88
+ shell: invocation.shell,
89
+ stdio: ["pipe", "pipe", "pipe"]
90
+ });
91
+
92
+ let stdoutBuffer = "";
93
+ let stderrBuffer = "";
94
+ let assistantText = "";
95
+ let stopReason = "unknown";
96
+ let usage: UsageBreakdown | undefined;
97
+ let policyViolation: SageRunnerPolicyError | undefined;
98
+
99
+ const toolUsage: ToolUsage = {
100
+ profile: policy.profile,
101
+ callsUsed: 0,
102
+ filesRead: 0,
103
+ bytesRead: 0
104
+ };
105
+
106
+ const failPolicy = (blockCode: BlockCode, reason: string): void => {
107
+ if (policyViolation) return;
108
+ policyViolation = new SageRunnerPolicyError(blockCode, reason);
109
+ child.kill("SIGTERM");
110
+ };
111
+
112
+ child.stdout.setEncoding("utf8");
113
+ child.stderr.setEncoding("utf8");
114
+
115
+ if (child.stdin) {
116
+ child.stdin.setDefaultEncoding("utf8");
117
+ child.stdin.write(prompt);
118
+ child.stdin.end();
119
+ }
120
+
121
+ child.stdout.on("data", (chunk: string) => {
122
+ stdoutBuffer += chunk;
123
+ const lines = stdoutBuffer.split(/\r?\n/);
124
+ stdoutBuffer = lines.pop() ?? "";
125
+
126
+ for (const line of lines) {
127
+ const parsed = parseJsonLine(line);
128
+ if (!parsed) continue;
129
+
130
+ if (parsed.type === "tool_execution_start") {
131
+ const toolName = parsed.toolName ?? "unknown";
132
+
133
+ if (!isToolAllowed(toolName, policy.allowedTools)) {
134
+ failPolicy("tool-disallowed", `Tool not allowed by Sage policy: ${toolName}`);
135
+ continue;
136
+ }
137
+
138
+ if (toolName === "bash") {
139
+ const command = extractBashCommand(parsed.args);
140
+ const decision = validateBashCommandForProfile(policy.profile, command ?? "");
141
+ if (!decision.ok) {
142
+ failPolicy(decision.blockCode ?? "tool-disallowed", decision.reason);
143
+ continue;
144
+ }
145
+ }
146
+
147
+ toolUsage.callsUsed += 1;
148
+ if (toolUsage.callsUsed > policy.maxToolCalls) {
149
+ failPolicy("volume-cap", "Exceeded max tool calls");
150
+ continue;
151
+ }
152
+
153
+ const candidatePaths = extractCandidatePaths(parsed.args);
154
+ for (const candidatePath of candidatePaths) {
155
+ const normalizedPath = isAbsolute(candidatePath) ? candidatePath : resolve(input.cwd, candidatePath);
156
+ const pathDecision = isPathAllowed(normalizedPath, [input.cwd], policy.sensitivePathDenylist);
157
+ if (!pathDecision.ok) {
158
+ failPolicy(pathDecision.blockCode ?? "path-denied", pathDecision.reason);
159
+ break;
160
+ }
161
+ }
162
+ }
163
+
164
+ if (parsed.type === "tool_execution_end") {
165
+ const chunkBytes = estimateContentBytes(parsed.result?.content);
166
+ toolUsage.bytesRead += chunkBytes;
167
+
168
+ if (chunkBytes > policy.maxBytesPerFile) {
169
+ failPolicy("volume-cap", "Exceeded max bytes per file");
170
+ continue;
171
+ }
172
+
173
+ if (toolUsage.bytesRead > policy.maxTotalBytesRead) {
174
+ failPolicy("volume-cap", "Exceeded max total bytes read");
175
+ continue;
176
+ }
177
+
178
+ if (parsed.toolName === "read") {
179
+ toolUsage.filesRead += 1;
180
+ if (toolUsage.filesRead > policy.maxFilesRead) {
181
+ failPolicy("volume-cap", "Exceeded max files read");
182
+ continue;
183
+ }
184
+ }
185
+ }
186
+
187
+ if (parsed.type === "message_end" && parsed.message?.role === "assistant") {
188
+ const text = extractAssistantText(parsed.message.content);
189
+ if (text.trim().length > 0) assistantText = text;
190
+ if (typeof parsed.message.stopReason === "string") stopReason = parsed.message.stopReason;
191
+ const parsedUsage = parseUsage(parsed.message.usage);
192
+ if (parsedUsage) usage = parsedUsage;
193
+ }
194
+ }
195
+ });
196
+
197
+ child.stderr.on("data", (chunk: string) => {
198
+ stderrBuffer += chunk;
199
+ });
200
+
201
+ let timedOut = false;
202
+ let escalationTimer: NodeJS.Timeout | undefined;
203
+
204
+ const timeout = setTimeout(() => {
205
+ timedOut = true;
206
+ try {
207
+ child.kill("SIGTERM");
208
+ } catch {
209
+ // Ignore kill errors
210
+ }
211
+
212
+ escalationTimer = setTimeout(() => {
213
+ try {
214
+ child.kill("SIGKILL");
215
+ } catch {
216
+ // Ignore kill errors
217
+ }
218
+ }, 2000);
219
+ }, input.timeoutMs);
220
+
221
+ if (input.signal) {
222
+ const onAbort = () => child.kill("SIGTERM");
223
+ input.signal.addEventListener("abort", onAbort, { once: true });
224
+ child.once("close", () => input.signal?.removeEventListener("abort", onAbort));
225
+ }
226
+
227
+ const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
228
+ child.once("error", (error) => reject(error));
229
+ child.once("close", (code, signal) => resolve({ code, signal }));
230
+ });
231
+
232
+ clearTimeout(timeout);
233
+ if (escalationTimer) clearTimeout(escalationTimer);
234
+
235
+ if (policyViolation) {
236
+ throw policyViolation;
237
+ }
238
+
239
+ if (stdoutBuffer.trim()) {
240
+ const parsed = parseJsonLine(stdoutBuffer.trim());
241
+ if (parsed?.type === "message_end" && parsed.message?.role === "assistant") {
242
+ const text = extractAssistantText(parsed.message.content);
243
+ if (text.trim().length > 0) assistantText = text;
244
+ if (typeof parsed.message.stopReason === "string") stopReason = parsed.message.stopReason;
245
+ const parsedUsage = parseUsage(parsed.message.usage);
246
+ if (parsedUsage) usage = parsedUsage;
247
+ }
248
+ }
249
+
250
+ const latencyMs = Date.now() - startedAt;
251
+
252
+ if (timedOut || (exit.signal === "SIGTERM" && latencyMs >= input.timeoutMs)) {
253
+ throw new Error("Sage subprocess timed out");
254
+ }
255
+
256
+ if (exit.code !== 0 && assistantText.trim().length === 0) {
257
+ throw new Error(`Sage subprocess failed (code ${String(exit.code)}): ${stderrBuffer.trim() || "no stderr"}`);
258
+ }
259
+
260
+ return {
261
+ text: assistantText.trim().length > 0 ? assistantText : `Sage returned no assistant text.${EOL}${stderrBuffer.trim()}`,
262
+ latencyMs,
263
+ stopReason,
264
+ usage,
265
+ toolUsage
266
+ };
267
+ }
268
+
269
+ function resolvePiInvocation(): { command: string; prefixArgs: string[]; shell: boolean } {
270
+ const envOverride = process.env.PI_BIN?.trim();
271
+
272
+ if (envOverride) {
273
+ const tokens = tokenizeCommand(envOverride);
274
+ const command = tokens.at(0);
275
+ if (command) {
276
+ return {
277
+ command,
278
+ prefixArgs: tokens.slice(1),
279
+ shell: false
280
+ };
281
+ }
282
+ }
283
+
284
+ if (process.platform === "win32") {
285
+ return {
286
+ command: "pi",
287
+ prefixArgs: [],
288
+ shell: true
289
+ };
290
+ }
291
+
292
+ return { command: "pi", prefixArgs: [], shell: false };
293
+ }
294
+
295
+ function tokenizeCommand(command: string): string[] {
296
+ const regex = /"([^"]*)"|'([^']*)'|(\S+)/g;
297
+ const tokens: string[] = [];
298
+
299
+ for (const match of command.matchAll(regex)) {
300
+ const token = match[1] ?? match[2] ?? match[3];
301
+ if (token) tokens.push(token);
302
+ }
303
+
304
+ return tokens;
305
+ }
306
+
307
+ function buildPiArgs(model: string, reasoningLevel: ReasoningLevel, cliTools: string[]): string[] {
308
+ const args = [
309
+ "--mode",
310
+ "json",
311
+ "-p",
312
+ "--no-session",
313
+ "--no-extensions",
314
+ "--model",
315
+ model,
316
+ "--thinking",
317
+ reasoningLevel
318
+ ];
319
+
320
+ if (cliTools.length === 0) {
321
+ args.push("--no-tools");
322
+ } else {
323
+ args.push("--tools", cliTools.join(","));
324
+ }
325
+
326
+ return args;
327
+ }
328
+
329
+ function buildSagePrompt(input: SageRunnerInput): string {
330
+ const lines: string[] = [];
331
+ lines.push("You are Sage, an advisory reasoning subagent.");
332
+ lines.push("Provide analysis and recommendations only. Do not claim to have applied changes.");
333
+ lines.push("Keep your response concise and actionable.");
334
+ lines.push("");
335
+ lines.push(`Question: ${input.question}`);
336
+
337
+ if (input.objective) lines.push(`Objective: ${input.objective}`);
338
+ if (input.urgency) lines.push(`Urgency: ${input.urgency}`);
339
+
340
+ if (input.context && input.context.trim()) {
341
+ lines.push("");
342
+ lines.push("Context:");
343
+ lines.push(input.context.trim());
344
+ }
345
+
346
+ if (input.files && input.files.length > 0) {
347
+ lines.push("");
348
+ lines.push("Relevant files:");
349
+ for (const file of input.files) lines.push(`- ${file}`);
350
+ }
351
+
352
+ if (input.evidence && input.evidence.length > 0) {
353
+ lines.push("");
354
+ lines.push("Evidence:");
355
+ for (const item of input.evidence) lines.push(`- ${item}`);
356
+ }
357
+
358
+ lines.push("");
359
+ lines.push("Required response format:");
360
+ lines.push("1) Assessment");
361
+ lines.push("2) Recommended next steps");
362
+ lines.push("3) Risks and confidence");
363
+
364
+ return lines.join(EOL);
365
+ }
366
+
367
+ function parseJsonLine(line: string): JsonEvent | undefined {
368
+ const trimmed = line.trim();
369
+ if (trimmed.length === 0) return undefined;
370
+ try {
371
+ const parsed = JSON.parse(trimmed) as unknown;
372
+ if (typeof parsed !== "object" || parsed === null) return undefined;
373
+ return parsed as JsonEvent;
374
+ } catch {
375
+ return undefined;
376
+ }
377
+ }
378
+
379
+ function extractAssistantText(content: Array<{ type?: string; text?: string }> | undefined): string {
380
+ if (!content) return "";
381
+ const parts = content
382
+ .filter((item) => item.type === "text" && typeof item.text === "string")
383
+ .map((item) => item.text ?? "");
384
+ return parts.join("").trim();
385
+ }
386
+
387
+ function parseUsage(usage: unknown): UsageBreakdown | undefined {
388
+ if (typeof usage !== "object" || usage === null) return undefined;
389
+
390
+ const usageRecord = usage as {
391
+ input?: number;
392
+ output?: number;
393
+ cacheRead?: number;
394
+ cacheWrite?: number;
395
+ totalTokens?: number;
396
+ cost?: { total?: number };
397
+ };
398
+
399
+ const input = usageRecord.input ?? 0;
400
+ const output = usageRecord.output ?? 0;
401
+ const cacheRead = usageRecord.cacheRead ?? 0;
402
+ const cacheWrite = usageRecord.cacheWrite ?? 0;
403
+ const totalTokens = usageRecord.totalTokens ?? input + output + cacheRead + cacheWrite;
404
+ const costTotal = usageRecord.cost?.total;
405
+
406
+ return {
407
+ input,
408
+ output,
409
+ cacheRead,
410
+ cacheWrite,
411
+ totalTokens,
412
+ costTotal
413
+ };
414
+ }
415
+
416
+ function estimateContentBytes(content: Array<{ type?: string; text?: string }> | undefined): number {
417
+ if (!content) return 0;
418
+ let total = 0;
419
+ for (const item of content) {
420
+ if (item.type === "text" && typeof item.text === "string") {
421
+ total += Buffer.byteLength(item.text, "utf8");
422
+ }
423
+ }
424
+ return total;
425
+ }
426
+
427
+ function extractCandidatePaths(args: unknown): string[] {
428
+ if (typeof args !== "object" || args === null) return [];
429
+
430
+ const obj = args as Record<string, unknown>;
431
+ const paths: string[] = [];
432
+
433
+ if (typeof obj.path === "string") paths.push(obj.path);
434
+ if (typeof obj.root === "string") paths.push(obj.root);
435
+ if (typeof obj.cwd === "string") paths.push(obj.cwd);
436
+
437
+ if (Array.isArray(obj.paths)) {
438
+ for (const value of obj.paths) {
439
+ if (typeof value === "string") paths.push(value);
440
+ }
441
+ }
442
+
443
+ return paths;
444
+ }
445
+
446
+ function extractBashCommand(args: unknown): string | undefined {
447
+ if (typeof args !== "object" || args === null) return undefined;
448
+ const obj = args as Record<string, unknown>;
449
+ return typeof obj.command === "string" ? obj.command : undefined;
450
+ }
451
+
452
+ function isToolAllowed(toolName: string, allowedTools: string[]): boolean {
453
+ const allowed = new Set(allowedTools);
454
+ if (allowed.has(toolName)) return true;
455
+
456
+ if (toolName === "find" && allowed.has("glob")) {
457
+ return true;
458
+ }
459
+
460
+ return false;
461
+ }