@sage-protocol/pi-adapter 0.2.0 → 0.2.2

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.
@@ -1,10 +1,13 @@
1
1
  import {
2
2
  SageHookRuntime,
3
3
  SageMcpBridge,
4
+ PiBehaviorExecutor,
4
5
  createSageExecuteTool,
5
6
  createSageSearchTool,
6
7
  createSageStatusTool,
7
8
  type SageP2Config,
9
+ type BehaviorPlan,
10
+ type ExecutionContext,
8
11
  } from '../dist/index.js';
9
12
 
10
13
  type ExtensionApi = {
@@ -28,6 +31,106 @@ function parseNumber(value: string | undefined, fallback: number): number {
28
31
  return parsed;
29
32
  }
30
33
 
34
+ function tokenizeShellish(command: string): string[] {
35
+ return command.match(/(?:[^\s"'`]+|"[^"]*"|'[^']*'|`[^`]*`)+/g) ?? [];
36
+ }
37
+
38
+ function stripMatchingQuotes(token: string): string {
39
+ if (token.length >= 2) {
40
+ const first = token[0];
41
+ const last = token[token.length - 1];
42
+ if ((first === '"' || first === "'" || first === '`') && first === last) {
43
+ return token.slice(1, -1);
44
+ }
45
+ }
46
+ return token;
47
+ }
48
+
49
+ function isShellAssignment(token: string): boolean {
50
+ return /^[A-Za-z_][A-Za-z0-9_]*=/.test(stripMatchingQuotes(token));
51
+ }
52
+
53
+ function parseShellAssignment(token: string): { name: string; value: string } | null {
54
+ const normalized = stripMatchingQuotes(token);
55
+ const match = normalized.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
56
+ if (!match) return null;
57
+ return { name: match[1], value: match[2] };
58
+ }
59
+
60
+ function getLeadingShellEnvironment(command: string): Record<string, string> {
61
+ const tokens = tokenizeShellish(command.trim());
62
+ const env: Record<string, string> = {};
63
+ if (tokens.length === 0) return env;
64
+
65
+ let index = 0;
66
+ while (index < tokens.length && isShellAssignment(tokens[index])) {
67
+ const assignment = parseShellAssignment(tokens[index]);
68
+ if (assignment) env[assignment.name] = assignment.value;
69
+ index += 1;
70
+ }
71
+
72
+ while (index < tokens.length) {
73
+ const token = stripMatchingQuotes(tokens[index]);
74
+ if (token === 'env') {
75
+ index += 1;
76
+ while (index < tokens.length && isShellAssignment(tokens[index])) {
77
+ const assignment = parseShellAssignment(tokens[index]);
78
+ if (assignment) env[assignment.name] = assignment.value;
79
+ index += 1;
80
+ }
81
+ continue;
82
+ }
83
+ if (token === 'command' || token === 'nohup') {
84
+ index += 1;
85
+ continue;
86
+ }
87
+ break;
88
+ }
89
+
90
+ return env;
91
+ }
92
+
93
+ function getFirstExecutableToken(command: string): string | null {
94
+ const tokens = tokenizeShellish(command.trim());
95
+ if (tokens.length === 0) return null;
96
+
97
+ let index = 0;
98
+ while (index < tokens.length && isShellAssignment(tokens[index])) {
99
+ index += 1;
100
+ }
101
+
102
+ while (index < tokens.length) {
103
+ const token = stripMatchingQuotes(tokens[index]);
104
+ if (token === 'env') {
105
+ index += 1;
106
+ while (index < tokens.length && isShellAssignment(tokens[index])) {
107
+ index += 1;
108
+ }
109
+ continue;
110
+ }
111
+ if (token === 'command' || token === 'nohup') {
112
+ index += 1;
113
+ continue;
114
+ }
115
+ return token;
116
+ }
117
+
118
+ return null;
119
+ }
120
+
121
+ function getCommandBasename(command: string): string {
122
+ const normalized = command.replace(/\\+/g, '/');
123
+ const parts = normalized.split('/');
124
+ return parts[parts.length - 1] ?? normalized;
125
+ }
126
+
127
+ function getInvokedSageCli(command: string): string | null {
128
+ const executable = getFirstExecutableToken(command);
129
+ if (!executable) return null;
130
+ const basename = getCommandBasename(executable);
131
+ return basename === 'sage' ? executable : null;
132
+ }
133
+
31
134
  function getSessionId(ctx: any): string | undefined {
32
135
  const sessionManager = ctx?.sessionManager;
33
136
  if (!sessionManager) return undefined;
@@ -83,6 +186,22 @@ function extractText(input: unknown): string {
83
186
  return '';
84
187
  }
85
188
 
189
+ function isContinuationPrompt(prompt: string): boolean {
190
+ const normalized = prompt.trim().toLowerCase();
191
+ if (!normalized) return false;
192
+ return [
193
+ 'continue',
194
+ 'please continue',
195
+ 'continue please',
196
+ 'go on',
197
+ 'please go on',
198
+ 'resume',
199
+ 'please resume',
200
+ 'keep going',
201
+ 'please keep going',
202
+ ].includes(normalized);
203
+ }
204
+
86
205
  function extractUsage(message: any): { input?: string; output?: string } {
87
206
  const usage =
88
207
  message?.usage && typeof message.usage === 'object'
@@ -107,8 +226,186 @@ function extractUsage(message: any): { input?: string; output?: string } {
107
226
  };
108
227
  }
109
228
 
229
+
230
+ /**
231
+ * Behavior-aware suggestion enrichment (Option A).
232
+ *
233
+ * When the suggest hook returns skills, check if any behavior entrypoints
234
+ * match the user's prompt. If so, prepend an enriched suggestion with:
235
+ * - The behavior chain (step names + skill keys + required/optional markers)
236
+ * - sage_behavior() call example for the full chain
237
+ * - use_skill() fallback for just the entrypoint
238
+ *
239
+ * False positive mitigation:
240
+ * 1. Lists ALL steps so the model can assess whether the chain is relevant
241
+ * 2. Marks optional steps explicitly — model can judge if the chain is overkill
242
+ * 3. Only enriches when ≥3 steps (avoids noise for thin behaviors)
243
+ * 4. Always offers both options (chain vs individual) — never forces the chain
244
+ * 5. Non-blocking — enrichment failure silently falls back to raw suggestions
245
+ */
246
+ async function enrichSuggestionsWithBehaviors(
247
+ rawSuggestions: string,
248
+ prompt: string,
249
+ bridge: InstanceType<typeof SageMcpBridge>,
250
+ ): Promise<string> {
251
+ if (!bridge.isReady() || !rawSuggestions.trim()) return rawSuggestions;
252
+
253
+ try {
254
+ // Search prompts (not skills) — behaviors are stored as prompt type entries
255
+ const searchResult = await bridge.callCodeModeSearch({
256
+ domain: 'prompts',
257
+ action: 'search',
258
+ params: { query: prompt, limit: 3 },
259
+ });
260
+
261
+ // Find behavior-tagged results
262
+ const behaviorKey = findBehaviorKeyFromSearch(searchResult);
263
+ if (!behaviorKey) return rawSuggestions;
264
+
265
+ // Fetch the full behavior plan to get step details
266
+ const planResult = await bridge.callCodeModeExecute({
267
+ domain: 'prompts',
268
+ action: 'get',
269
+ params: { key: behaviorKey, expand: ['behaviorResolved'] },
270
+ });
271
+
272
+ const behavior = extractBehaviorFromPlanResult(planResult, behaviorKey);
273
+ if (!behavior || behavior.steps.length < 3) return rawSuggestions;
274
+
275
+ const enrichment = formatBehaviorSuggestion(behavior, prompt);
276
+ return `${enrichment}\n\n---\n\n${rawSuggestions}`;
277
+ } catch {
278
+ return rawSuggestions;
279
+ }
280
+ }
281
+
282
+ /** Find the first behavior-type prompt key from search results. */
283
+ function findBehaviorKeyFromSearch(result: unknown): string | null {
284
+ if (!result || typeof result !== 'object') return null;
285
+
286
+ const obj = result as Record<string, unknown>;
287
+ if (!Array.isArray(obj.content)) return null;
288
+
289
+ for (const block of obj.content) {
290
+ if (!block || typeof block !== 'object') continue;
291
+ const text = (block as Record<string, unknown>).text;
292
+ if (typeof text !== 'string') continue;
293
+
294
+ try {
295
+ const parsed = JSON.parse(text);
296
+ const results = Array.isArray(parsed.results) ? parsed.results : [];
297
+ for (const item of results) {
298
+ if (!item || typeof item !== 'object') continue;
299
+ // Detect behavior entries by tags or promptType
300
+ const tags = Array.isArray(item.tags) ? item.tags : [];
301
+ const promptType = item.promptType ?? item.type ?? '';
302
+ if (tags.includes('behavior') || promptType === 'behavior') {
303
+ const key = typeof item.key === 'string' ? item.key : '';
304
+ if (key) return key;
305
+ }
306
+ }
307
+ } catch {
308
+ // Not JSON — skip
309
+ }
310
+ }
311
+
312
+ return null;
313
+ }
314
+
315
+ interface BehaviorMatch {
316
+ key: string;
317
+ name: string;
318
+ description: string;
319
+ steps: Array<{ key: string; title: string; required: boolean }>;
320
+ }
321
+
322
+ /** Extract behavior step info from a get_prompt response with behaviorResolved expand. */
323
+ function extractBehaviorFromPlanResult(result: unknown, key: string): BehaviorMatch | null {
324
+ if (!result || typeof result !== 'object') return null;
325
+
326
+ const obj = result as Record<string, unknown>;
327
+ if (!Array.isArray(obj.content)) return null;
328
+
329
+ for (const block of obj.content) {
330
+ if (!block || typeof block !== 'object') continue;
331
+ const text = (block as Record<string, unknown>).text;
332
+ if (typeof text !== 'string') continue;
333
+
334
+ try {
335
+ const parsed = JSON.parse(text);
336
+ const prompt = parsed.prompt ?? parsed;
337
+
338
+ // Get step info from behaviorResolved (has skill metadata)
339
+ const resolved = prompt.behaviorResolved ?? prompt.behaviorPlan;
340
+ const behaviorSpec = prompt.behavior;
341
+
342
+ // Build steps from behavior spec (has required/alias info)
343
+ if (behaviorSpec && Array.isArray(behaviorSpec.skills)) {
344
+ const steps = behaviorSpec.skills.map((s: unknown) => {
345
+ if (typeof s === 'string') return { key: s, title: s, required: true };
346
+ if (s && typeof s === 'object') {
347
+ const so = s as Record<string, unknown>;
348
+ const skillKey = typeof so.key === 'string' ? so.key : '';
349
+ return {
350
+ key: skillKey,
351
+ title: typeof so.alias === 'string' ? so.alias : skillKey,
352
+ required: so.required !== false,
353
+ };
354
+ }
355
+ return { key: String(s), title: String(s), required: true };
356
+ });
357
+
358
+ // Check for missing required skills from resolved data
359
+ const missing = resolved?.missing ?? [];
360
+
361
+ return {
362
+ key,
363
+ name: typeof prompt.name === 'string' ? prompt.name : key,
364
+ description: typeof prompt.description === 'string' ? prompt.description : '',
365
+ steps: steps.filter((s: { key: string }) => !missing.includes(s.key)),
366
+ };
367
+ }
368
+ } catch {
369
+ // Not JSON — skip
370
+ }
371
+ }
372
+
373
+ return null;
374
+ }
375
+
376
+ function formatBehaviorSuggestion(behavior: BehaviorMatch, prompt: string): string {
377
+ const lines: string[] = [];
378
+
379
+ lines.push("[chain] **Behavior workflow available: " + behavior.name + "** (" + behavior.steps.length + " steps)");
380
+ lines.push("");
381
+
382
+ // List steps with required/optional markers — false positive mitigation.
383
+ // The model reads this list and decides if the full chain matches the intent.
384
+ lines.push("Steps:");
385
+ for (let i = 0; i < behavior.steps.length; i++) {
386
+ const step = behavior.steps[i];
387
+ const marker = step.required ? "" : " _(optional)_";
388
+ const label = step.title !== step.key
389
+ ? step.title + " (" + step.key + ")"
390
+ : step.key;
391
+ lines.push(" " + (i + 1) + ". " + label + marker);
392
+ }
393
+ lines.push("");
394
+
395
+ const safePrompt = prompt.replace(/"/g, '\\"').slice(0, 120);
396
+
397
+ lines.push("**Full chain** (recommended for comprehensive workflows):");
398
+ lines.push(" sage_behavior({ key: \"" + behavior.key + "\", task: \"" + safePrompt + "\" })");
399
+ lines.push("");
400
+ lines.push("**Just the entrypoint** (for quick routing without the full pipeline):");
401
+ lines.push(" sage_execute({ domain: \"skills\", action: \"use\", params: { key: \"" + behavior.key + "\" } })");
402
+
403
+ return lines.join("\n");
404
+ }
405
+
110
406
  export default function sagePiAdapterExtension(pi: ExtensionApi): void {
111
407
  const showHookMessages = parseBoolean(process.env.SAGE_SHOW_HOOK_MESSAGES, false);
408
+ const allowBashSageCli = parseBoolean(process.env.SAGE_ALLOW_BASH_CLI, true);
112
409
  const config: SageP2Config = {
113
410
  sageBin: process.env.SAGE_BIN ?? 'sage',
114
411
  sageProfile: process.env.SAGE_PROFILE,
@@ -141,85 +438,175 @@ export default function sagePiAdapterExtension(pi: ExtensionApi): void {
141
438
  console.error('[sage-pi-adapter] failed to start MCP bridge:', error);
142
439
  });
143
440
 
441
+ const behaviorExecutor = new PiBehaviorExecutor({
442
+ bridge,
443
+ sendMessage: (msg) => pi.sendMessage(msg, { deliverAs: 'nextTurn' }),
444
+ });
445
+
144
446
  pi.registerTool(createSageSearchTool(bridge));
145
447
  pi.registerTool(createSageExecuteTool(bridge));
146
448
  pi.registerTool(createSageStatusTool(bridge));
147
449
 
450
+ // Behavior execution tool — resolves and executes Sage behavior plans.
451
+ // The agent calls this to run a multi-skill behavior chain.
452
+ pi.registerTool({
453
+ name: 'sage_behavior',
454
+ label: 'Sage: Behavior',
455
+ promptSnippet: 'Resolve and execute a Sage behavior plan (multi-skill workflow chain)',
456
+ description:
457
+ 'Resolve a behavior key into a multi-skill execution plan and render it as a structured ' +
458
+ 'controller prompt. Behaviors chain multiple skills into a linear workflow — e.g., ' +
459
+ 'entry-plan-review chains: sage → reverse-interviewing → pre-mortem → ceo-plan-review → ' +
460
+ 'plan-eng-review → openprose → confidence-calibration. ' +
461
+ 'Call with key=\'entry-plan-review\' and task=\'Review my implementation plan\'. ' +
462
+ 'The tool returns a structured execution plan for the agent to follow step by step.',
463
+ parameters: {
464
+ type: 'object' as const,
465
+ properties: {
466
+ key: {
467
+ type: 'string' as const,
468
+ description: 'Behavior key to resolve (e.g., "entry-plan-review", "brainstorm-to-action-pipeline")',
469
+ },
470
+ task: {
471
+ type: 'string' as const,
472
+ description: 'The user\'s task or prompt that triggered this behavior',
473
+ },
474
+ },
475
+ required: ['key', 'task'],
476
+ },
477
+ execute: async (_toolCallId: string, params: Record<string, unknown>) => {
478
+ const key = typeof params.key === 'string' ? params.key : '';
479
+ const task = typeof params.task === 'string' ? params.task : '';
480
+
481
+ if (!key) {
482
+ return {
483
+ content: [{ type: 'text', text: 'Missing required parameter: key' }],
484
+ isError: true,
485
+ };
486
+ }
487
+
488
+ try {
489
+ const plan: BehaviorPlan = await behaviorExecutor.resolve(key);
490
+
491
+ if (!behaviorExecutor.canExecute(plan)) {
492
+ return {
493
+ content: [{ type: 'text', text: `Behavior '${key}' resolved but cannot be executed (empty plan)` }],
494
+ isError: true,
495
+ };
496
+ }
497
+
498
+ const context: ExecutionContext = {
499
+ task: task || `Execute behavior: ${key}`,
500
+ cwd: process.cwd(),
501
+ mcpServersRequired: [],
502
+ };
503
+
504
+ const result = await behaviorExecutor.execute(plan, context);
505
+
506
+ return {
507
+ content: [
508
+ {
509
+ type: 'text',
510
+ text: result.output ?? `Behavior '${key}' plan rendered with ${plan.steps.length} steps.`,
511
+ },
512
+ ],
513
+ };
514
+ } catch (error) {
515
+ return {
516
+ content: [
517
+ {
518
+ type: 'text',
519
+ text: `Failed to resolve/execute behavior '${key}': ${error instanceof Error ? error.message : String(error)}`,
520
+ },
521
+ ],
522
+ isError: true,
523
+ };
524
+ }
525
+ },
526
+ });
527
+
148
528
  // ---------------------------------------------------------------------------
149
529
  // System prompt injection: Sage-first routing for Pi sessions
150
530
  // ---------------------------------------------------------------------------
151
531
  const injectToolDocs = parseBoolean(process.env.SAGE_INJECT_TOOL_DOCS, true);
152
532
 
153
- const SAGE_TOOL_DOCS = `
533
+ // Tier 1: Tool-level docs via promptSnippet/promptGuidelines (always active, ~200 tokens).
534
+ // Tier 2: Full usage patterns injected once at session_start (below).
535
+ // Tier 3: Complete CLI reference available on-demand via sage_status({}).
536
+ //
537
+ // This per-turn string is a minimal guardrail that complements tool-level docs.
538
+ // It does NOT repeat usage patterns — those are in Tier 2 (session_start).
539
+ const SAGE_PER_TURN_GUARDRAIL = `
154
540
  [Sage Tools - Internet Access]
155
541
  You have sage_search, sage_execute, and sage_status tools for internet and external service access.
156
- IMPORTANT: NEVER use bash to run 'sage' CLI commands. ALWAYS use the sage_search, sage_execute, or sage_status tools instead.
542
+ Prefer Sage MCP tools for search, discovery, external research, URL fetches, and MCP server lifecycle management. Direct \`sage ...\` CLI commands are also allowed when runtime verification, local operational checks, or founder workflows require them.
157
543
  Sage internal tools are available immediately. Only external MCP servers need lifecycle management, and pi preserves a hub-compat wrapper for that flow.
544
+ `.trim();
545
+
546
+ // Tier 2: Full Sage protocol context injected ONCE at session_start.
547
+ // Includes skill trigger workflow, external research patterns, hub management,
548
+ // library/DAO/bounty recipes, and best practices — everything the model needs
549
+ // to use Sage effectively, but only needs to see once per session.
550
+ const SAGE_SESSION_CONTEXT = `
551
+ ## Sage MCP + CLI Routing
552
+
553
+ You can access Sage through MCP tools (preferred in agent runs) and Rust CLI commands.
158
554
 
159
- Skill trigger workflow:
160
- 1) Discover: sage_search({ domain: "skills", action: "search", params: { query: "..." } })
161
- 2) Inspect: sage_search({ domain: "skills", action: "get", params: { key: "..." } })
162
- 3) Activate: sage_execute({ domain: "skills", action: "use", params: { key: "..." } })
555
+ ### Skill Trigger Workflow
556
+ 1. Discover: sage_search({ domain: "skills", action: "search", params: { query: "..." } })
557
+ 2. Inspect: sage_search({ domain: "skills", action: "get", params: { key: "..." } })
558
+ 3. Activate: sage_execute({ domain: "skills", action: "use", params: { key: "..." } })
163
559
 
164
- Prompt discovery:
560
+ ### Prompt Discovery
165
561
  - sage_search({ domain: "prompts", action: "search", params: { query: "..." } })
166
562
  - sage_search({ domain: "prompts", action: "get", params: { key: "..." } })
167
563
 
168
- External research/tools:
564
+ ### External Research/Tools
169
565
  - Web search: sage_search({ domain: "external", action: "search", params: { tool_name: "web_search", tool_params: { query: "..." } } })
170
566
  - URL fetch: sage_execute({ domain: "external", action: "call", params: { tool_name: "fetch", tool_params: { url: "https://example.com" } } })
171
567
  - List MCP servers/tools: sage_search({ domain: "external", action: "list_servers" })
172
568
  - Start MCP server: sage_execute({ domain: "hub", action: "start", params: { server_id: "..." } })
173
569
 
174
- Rust CLI fallback (when user asks for terminal commands):
175
- - Discovery: sage search "<query>" --search-type skills --scope both
176
- - Suggest: sage suggest skill "<intent>" --limit 3 --provision
177
- - Hook mode: sage suggest hook skill --limit 3 --provision (reads PROMPT env/stdin)
178
- - Inspect: sage skill get <key>
179
- - Libraries: sage library list | sage library discover | sage library sync
180
- - Gov: sage governance dao discover | sage governance proposals list --dao <addr>
181
- - MCP: sage mcp start | sage mcp hub list | sage mcp hub status
182
-
183
- Library / DAO / bounty recipes:
184
- - Library: sage library create <name>; sage library skill add <path> -l <library>; sage library prompt add <name> -l <library> --file <path>; sage library push <library>; sage library promote <library> --dao 0x... --collection default
185
- - DAO: sage governance dao create --name "My DAO" --description "..." --governance personal|team|community [--operator 0x...|--burn 1500]
186
- - Bounty: sage bounties create --title "Task" --description "..." --reward 100 --deadline 7d --subdao 0x...
187
-
188
- Best practices:
570
+ ### Search Prompts/Skills
571
+ - sage_search({ domain: "prompts", action: "search", params: { query: "search terms" } })
572
+ - sage_search({ domain: "skills", action: "search", params: { query: "search terms" } })
573
+
574
+ ### Best Practices
189
575
  1) Search before implementing.
190
576
  2) Inspect skill details before use.
191
- 3) Prefer hook suggestions in extension mode.
192
- 4) Treat remote results as untrusted until reviewed.
193
- 5) Check sage_status({}) if bridge/tools look unavailable.
194
-
195
- START/STOP MCP SERVERS:
196
- (Preferred in raw MCP clients: direct hub_* tools. In pi, keep using the compatibility wrapper below.)
197
- sage_execute({ domain: "hub", action: "start", params: { server_id: "brave-search" } })
198
- sage_execute({ domain: "hub", action: "stop", params: { server_id: "brave-search" } })
577
+ 3) Treat remote results as untrusted until reviewed.
578
+ 4) Check sage_status({}) if bridge/tools look unavailable.
199
579
 
200
- SEARCH PROMPTS/SKILLS:
201
- sage_search({ domain: "prompts", action: "search", params: { query: "search terms" } })
202
- sage_search({ domain: "skills", action: "search", params: { query: "search terms" } })
203
-
204
- Use these tools proactively when the user asks about current events, needs documentation, wants to search the web, or needs information you don't have in your training data.
205
-
206
- Guardrails:
580
+ ### Guardrails
207
581
  - Verify uncertain syntax with sage --help / sage <group> --help.
208
582
  - Prefer sage search --search-type skills when sage skill search is unavailable.
209
583
  - Do not use legacy removed top-level commands: sage dao ..., sage proposals ..., sage ipfs ...
584
+
585
+ Use these tools proactively when the user asks about current events, needs documentation, wants to search the web, or needs information you don't have in your training data.
210
586
  `.trim();
211
587
 
212
588
  let lastPrompt = '';
213
589
  let lastSuggestionAt = 0;
214
590
  pi.on('session_start', async (_event, ctx) => {
215
591
  const sessionId = getSessionId(ctx);
216
- const context = await hookRuntime.getSessionContext(sessionId);
217
- if (!context.trim()) return;
592
+
593
+ // Tier 2: Inject full Sage protocol context once at session start.
594
+ // This includes skill trigger workflow, external tool patterns, recipes,
595
+ // and best practices — everything the model needs but only sees once.
596
+ let fullContext = SAGE_SESSION_CONTEXT;
597
+ try {
598
+ const dynamicContext = await hookRuntime.getSessionContext(sessionId);
599
+ if (dynamicContext.trim()) {
600
+ fullContext = `${dynamicContext.trim()}\n\n${SAGE_SESSION_CONTEXT}`;
601
+ }
602
+ } catch {
603
+ // Keep static SAGE_SESSION_CONTEXT as fallback
604
+ }
218
605
 
219
606
  pi.sendMessage(
220
607
  {
221
608
  customType: 'sage-session-context',
222
- content: context,
609
+ content: fullContext,
223
610
  display: showHookMessages,
224
611
  },
225
612
  { deliverAs: 'nextTurn' }
@@ -229,6 +616,7 @@ Guardrails:
229
616
  pi.on('before_agent_start', async (event, ctx) => {
230
617
  const prompt = typeof event?.prompt === 'string' ? event.prompt.trim() : '';
231
618
  if (!prompt) return;
619
+ const continuationPrompt = isContinuationPrompt(prompt);
232
620
 
233
621
  const sessionId = getSessionId(ctx);
234
622
  await hookRuntime.capturePrompt(prompt, sessionId);
@@ -240,23 +628,95 @@ Guardrails:
240
628
  if (injectToolDocs) {
241
629
  const currentSystemPrompt = typeof event?.systemPrompt === 'string' ? event.systemPrompt : '';
242
630
  if (currentSystemPrompt) {
243
- let docs = SAGE_TOOL_DOCS;
631
+ let docs = SAGE_PER_TURN_GUARDRAIL;
244
632
  try {
245
633
  const sessionContext = await hookRuntime.getSessionContext(sessionId);
246
634
  if (sessionContext.trim()) {
247
- docs = `${sessionContext.trim()}\n\n${SAGE_TOOL_DOCS}`;
635
+ docs = `${sessionContext.trim()}\n\n${SAGE_PER_TURN_GUARDRAIL}`;
636
+ }
637
+ } catch {
638
+ // Session-context injection is best-effort — never block the turn.
639
+ }
640
+
641
+ // Dynamic identity context: wallet, libraries, skill counts.
642
+ // Cached for 60s so the CLI cost is negligible per-turn.
643
+ try {
644
+ const identity = await hookRuntime.getIdentityContext();
645
+ if (identity) {
646
+ docs = `${identity}\n\n${docs}`;
248
647
  }
249
648
  } catch {
250
- // Ignore context injection failures and keep static docs fallback.
649
+ // Identity injection is best-effort — never block the turn.
251
650
  }
252
651
 
253
652
  modifiedSystemPrompt = currentSystemPrompt + '\n\n' + docs;
254
653
  }
255
654
  }
256
655
 
656
+ // --- Skill auto-resolution for subagents ---
657
+ // Detect skill names referenced in the prompt/task and inject their content
658
+ // so subagents (which may not inherit Pi's package-resolved skills) can use them.
659
+ const enableSkillResolve = parseBoolean(process.env.SAGE_AUTO_RESOLVE_SKILLS, true);
660
+ if (enableSkillResolve && modifiedSystemPrompt) {
661
+ // Match patterns like: "Skills not found: foo, bar" or explicit skill refs
662
+ const skillNotFoundMatch = prompt.match(/skills?\s+not\s+found:\s*([^\n]+)/i);
663
+ const useSkillMatch = prompt.match(/(?:use|load|activate)\s+skills?:\s*([^\n]+)/i);
664
+ // Match "skills: foo, bar, baz" lines (common in subagent task descriptions)
665
+ const skillsListMatch = prompt.match(/^\s*skills?:\s*([a-z0-9_-]+(?:\s*,\s*[a-z0-9_-]+)*)\s*$/im);
666
+
667
+ const skillNames = new Set<string>();
668
+ for (const match of [skillNotFoundMatch, useSkillMatch, skillsListMatch]) {
669
+ if (match?.[1]) {
670
+ for (const name of match[1].split(/[,;]+/)) {
671
+ const trimmed = name.trim().toLowerCase().replace(/['"]/g, '');
672
+ if (trimmed) skillNames.add(trimmed);
673
+ }
674
+ }
675
+ }
676
+
677
+ if (skillNames.size > 0) {
678
+ try {
679
+ const resolved = await hookRuntime.resolveSkills([...skillNames]);
680
+
681
+ // Fall back to sage MCP bridge for skills not found locally.
682
+ // This resolves sage library-stored prompts/skills that don't have
683
+ // a local SKILL.md (e.g., "sharp-edges" from a remote sage library).
684
+ const unresolved = [...skillNames].filter((name) => !resolved.has(name));
685
+ if (unresolved.length > 0 && bridge.isReady()) {
686
+ for (const name of unresolved) {
687
+ try {
688
+ const result = await bridge.callCodeModeSearch({
689
+ domain: 'skills',
690
+ action: 'get',
691
+ params: { key: name },
692
+ });
693
+ const text = extractText(result);
694
+ if (text && text.length > 50) {
695
+ resolved.set(name, text);
696
+ }
697
+ } catch {
698
+ // Skill not in sage library either — skip silently
699
+ }
700
+ }
701
+ }
702
+
703
+ if (resolved.size > 0) {
704
+ const skillBlocks: string[] = [];
705
+ for (const [name, content] of resolved) {
706
+ skillBlocks.push(`\n--- Sage Skill: ${name} ---\n${content}\n--- End Skill: ${name} ---`);
707
+ }
708
+ modifiedSystemPrompt += '\n\n[Auto-resolved Sage Skills]\n' + skillBlocks.join('\n');
709
+ }
710
+ } catch {
711
+ // Non-blocking — subagent proceeds without injected skills
712
+ }
713
+ }
714
+ }
715
+
257
716
  // --- Skill suggestions (debounced) ---
258
717
  const now = Date.now();
259
718
  const shouldSuggest =
719
+ !continuationPrompt &&
260
720
  now - lastSuggestionAt >= (config.suggestDebounceMs ?? 800) && prompt !== lastPrompt;
261
721
 
262
722
  let suggestionMessage: { customType: string; content: string; display: boolean } | undefined;
@@ -264,7 +724,20 @@ Guardrails:
264
724
  lastPrompt = prompt;
265
725
  lastSuggestionAt = now;
266
726
 
267
- const suggestions = await hookRuntime.suggestSkill(prompt, sessionId);
727
+ let suggestions = await hookRuntime.suggestSkill(prompt, sessionId);
728
+
729
+ // Option A: Enrich suggestions with behavior chain info when a
730
+ // behavior entrypoint matches. This gives the model both options
731
+ // (full chain via sage_behavior OR individual skill via use_skill)
732
+ // and lists all steps for false-positive assessment.
733
+ if (suggestions.trim()) {
734
+ try {
735
+ suggestions = await enrichSuggestionsWithBehaviors(suggestions, prompt, bridge);
736
+ } catch {
737
+ // Enrichment failure is non-blocking — use raw suggestions
738
+ }
739
+ }
740
+
268
741
  if (suggestions.trim()) {
269
742
  suggestionMessage = {
270
743
  customType: 'sage-suggested-skills',
@@ -310,11 +783,13 @@ Guardrails:
310
783
  // --- Intercept bash calls to sage CLI and redirect to sage_search/sage_execute ---
311
784
  if (toolName === 'bash') {
312
785
  const command = typeof toolInput.command === 'string' ? toolInput.command : '';
313
- // Match: sage mcp ..., sage suggest ..., sage search ..., sage hub ..., sage skill ...
314
- const sageCliPattern = /\bsage\s+(mcp|suggest|search|hub|skill|prompt|status|execute|call)\b/i;
315
- if (sageCliPattern.test(command)) {
786
+ const invokedSageCli = getInvokedSageCli(command);
787
+ const inlineEnv = getLeadingShellEnvironment(command);
788
+ const allowDirectSageCli = parseBoolean(inlineEnv.SAGE_ALLOW_BASH_CLI, allowBashSageCli);
789
+ if (invokedSageCli && !allowDirectSageCli) {
316
790
  // Build a helpful redirect message based on what the model was trying to do
317
- let redirect = 'Do NOT use bash to call the sage CLI. Use the sage_search, sage_execute, or sage_status Pi tools instead.\n\n';
791
+ let redirect = 'Direct sage CLI execution is disabled in this harness. Use the sage_search, sage_execute, or sage_status Pi tools instead.\n';
792
+ redirect += 'To re-enable direct CLI execution, set SAGE_ALLOW_BASH_CLI=1 before starting pi or prefix this command with env SAGE_ALLOW_BASH_CLI=1.\n\n';
318
793
 
319
794
  if (/sage\s+mcp\s+hub\s+list/i.test(command)) {
320
795
  redirect += 'To list available servers: sage_search({ domain: "external", action: "list_servers" })\n';