@gotgenes/pi-permission-system 36.2.1 → 38.0.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.
@@ -2,36 +2,18 @@
2
2
  * The tool-surface region of a system prompt: which tools this session may
3
3
  * call, and the guidance those tools contribute.
4
4
  *
5
- * Pi writes that region near the top of its preamble, a few hundred characters
6
- * in. `@gotgenes/pi-subagents` copies everything ahead of the skills catalogue
7
- * into a child's prompt verbatim, so the child's leading bytes match its
8
- * parent's for prefix-reusing inference engines — which means editing the
9
- * region in place ends that shared prefix for every child whose allowed set
10
- * differs from its parent's (#890).
5
+ * Pi renders that region itself, as its `<tools>` and `<rules>` sections, from
6
+ * the active tool set this extension narrows — but only when it writes the
7
+ * preamble. A subagent child's prompt is always a `customPrompt`, under which
8
+ * Pi writes neither section, so a child states its own through
9
+ * `systemPromptOptions.sections`, which Pi places after its `<cwd>` section.
10
+ * This module renders those section contents.
11
11
  *
12
- * So the region is *relocated* rather than narrowed: the sections Pi wrote are
13
- * removed, and this node's own are rendered at the end of the prompt, past
14
- * everything a child inherits. Each session then states its own tool surface
15
- * and no session edits another's bytes.
16
- *
17
- * Removal is bounded to the text this package or Pi wrote. The prompt is split
18
- * at the cwd layer Pi writes last and unconditionally: a
19
- * `Current working directory:` footer through Pi 0.85, a `<cwd>` section from
20
- * 0.86. Everything after it was appended by an extension, and everything
21
- * before it is Pi's own only when Pi did not build the prompt from a
22
- * `customPrompt`. Under a custom prompt Pi writes no tool surface at all, so a
23
- * section matched above the cwd layer is a user's or another extension's, and
24
- * removing it destroyed their text (#919, #932). On the 0.86 shape even a
25
- * Pi-authored head is searched only for Pi's own `<tools>` and `<rules>`,
26
- * bounded to where Pi writes them, since the plain headers it no longer
27
- * writes would match only a context file's text.
28
- *
29
- * Rendering follows `buildSystemPrompt`'s own rules (a tool is listed only
30
- * when it has a snippet; the guideline bullets are the allowed tools' own
31
- * `promptGuidelines`, then other extensions' rules, around Pi's built-in
32
- * ones) and its shape (plain headers or tagged sections, matching the
33
- * prompt), so the block reads as the one Pi would have written for this
34
- * session's real surface.
12
+ * Rendering follows `buildSystemPrompt`'s own rules: a tool is listed only
13
+ * when it has a snippet, and the guideline bullets are the allowed tools' own
14
+ * `promptGuidelines`, then other extensions' rules, around Pi's built-in ones,
15
+ * so the sections read as the ones Pi would have written for this session's
16
+ * real surface.
35
17
  */
36
18
 
37
19
  /** What a session's tool surface renders from. */
@@ -43,78 +25,25 @@ export interface ToolSurfaceInputs {
43
25
  /** Guideline bullets each tool contributes, keyed by tool name. */
44
26
  readonly guidelinesByTool: ReadonlyMap<string, readonly string[]>;
45
27
  /**
46
- * `systemPromptOptions.promptGuidelines`: bullets Pi writes into its rules
47
- * after the tools' own.
28
+ * `systemPromptOptions.promptGuidelines`: the bullets other extensions add
29
+ * to Pi's rules after the tools' own.
48
30
  *
49
- * Only a bullet no registered tool contributes is carried. From Pi 0.86 the
50
- * field holds what other extensions added; through 0.85 it *is* the tools'
51
- * guidelines, flattened, and a denied tool's bullet must not return by this
52
- * route.
31
+ * Only a bullet no registered tool contributes is carried, so a denied
32
+ * tool's bullet cannot return by this route.
53
33
  */
54
34
  readonly promptGuidelines: readonly string[];
55
- /**
56
- * Whether Pi wrote the prompt's preamble itself.
57
- *
58
- * False when Pi assembled the prompt from `customPrompt` — a user's
59
- * SYSTEM.md, or a subagent child's assembled prompt — in which case Pi wrote
60
- * no tool-surface sections and every line above its footer belongs to
61
- * somebody else.
62
- */
63
- readonly piAuthoredPreamble: boolean;
64
35
  }
65
36
 
66
- type LineSection = {
67
- start: number;
68
- end: number;
69
- };
70
-
71
- const AVAILABLE_TOOLS_SECTION_HEADER = "Available tools:";
72
- const GUIDELINES_SECTION_HEADER = "Guidelines:";
73
-
74
- /**
75
- * Pi's filler sentence between the tool list and the guidelines.
76
- *
77
- * It refers to "the tools above", so it belongs with the list rather than with
78
- * the text the list is being moved out of.
79
- */
80
- const CUSTOM_TOOLS_FILLER_PREFIX = "In addition to the tools above";
81
-
82
- /** What Pi writes under `Available tools:` when no selected tool has a snippet. */
83
- const EMPTY_LIST_PLACEHOLDER = "(none)";
84
-
85
- /**
86
- * The first line of the footer Pi writes last, in both of its branches.
87
- *
88
- * It is the boundary between what Pi assembled and what extensions appended
89
- * after it — the same anchor `@gotgenes/pi-subagents` uses to find Pi's
90
- * session-resolved tail.
91
- */
92
- const PROMPT_FOOTER_PREFIX = "Current working directory: ";
93
-
94
- /**
95
- * The tags of the section Pi renders the working directory into from 0.86,
96
- * last among its own sections and in both of its branches.
97
- */
98
- const CWD_SECTION_OPEN = "<cwd>";
99
- const CWD_SECTION_CLOSE = "</cwd>";
100
-
101
- /** The sections Pi 0.86+ writes its tool surface into, in the order it writes them. */
102
- const TOOL_SURFACE_SECTION_NAMES = ["tools", "rules"] as const;
103
-
104
37
  /**
105
- * Opening tags of the sections Pi writes after its tool surface.
106
- *
107
- * Pi writes `<tools>` and `<rules>` directly under its preamble and ahead of
108
- * every one of these, so a match that opens or closes past the first of them
109
- * is text somebody quoted, not Pi's.
38
+ * This session's tool surface as the contents of Pi's `tools` and `rules`
39
+ * prompt sections, untagged: Pi wraps each `sections` entry in its own tags.
110
40
  */
111
- const LATER_PI_SECTION_OPENS: ReadonlySet<string> = new Set([
112
- "<docs>",
113
- "<addendum>",
114
- "<project_context>",
115
- "<skills>",
116
- CWD_SECTION_OPEN,
117
- ]);
41
+ export interface ToolSurfaceSections {
42
+ /** `- name: snippet` lines; absent when no allowed tool has a snippet. */
43
+ readonly tools?: string;
44
+ /** `- rule` lines, in `buildSystemPrompt`'s order. */
45
+ readonly rules: string;
46
+ }
118
47
 
119
48
  /** Pi's two unconditional guideline bullets, in the order it writes them. */
120
49
  const UNIVERSAL_GUIDELINES: readonly string[] = [
@@ -123,299 +52,18 @@ const UNIVERSAL_GUIDELINES: readonly string[] = [
123
52
  ];
124
53
 
125
54
  /**
126
- * Relocate the tool surface: drop the sections Pi wrote, append this session's.
55
+ * Render this session's tool surface as section contents, for a node that
56
+ * states it through `systemPromptOptions.sections`.
127
57
  *
128
- * The result always carries a tool-surface block, so a child whose inherited
129
- * identity has none — its parent's node having already relocated it — still
130
- * describes its own tools.
58
+ * The `tools` section is omitted when no allowed tool has a snippet, as Pi
59
+ * lists a tool only when it has one.
131
60
  */
132
- export function renderToolSurface(
133
- systemPrompt: string,
61
+ export function renderToolSurfaceSections(
134
62
  inputs: ToolSurfaceInputs,
135
- ): string {
136
- const lines = normalizePrompt(systemPrompt).split("\n");
137
- const { layout, tailStart } = detectPromptLayout(lines);
138
- const body = [
139
- settleRegion(
140
- lines.slice(0, tailStart),
141
- inputs.piAuthoredPreamble ? layout.removePiSurface : null,
142
- ),
143
- settleRegion(lines.slice(tailStart), layout.removeRelocatedSurface),
144
- ]
145
- .filter((region) => region.length > 0)
146
- .join("\n")
147
- .trimEnd();
148
- const block = layout.renderBlock(toolSurfaceBullets(inputs));
149
-
150
- return body.length > 0 ? `${body}\n\n${block}` : block;
151
- }
152
-
153
- /** Lines removed from one region of the prompt; the rest are returned in order. */
154
- type RegionRemoval = (lines: readonly string[]) => string[];
155
-
156
- /**
157
- * How one prompt shape bounds and writes the tool surface.
158
- *
159
- * Pi has written its prompt in more than one shape, and each shape answers the
160
- * same three questions differently, so the shape is decided once per prompt
161
- * and every answer is read off the layout it selected.
162
- */
163
- interface PromptLayout {
164
- /** Pi's own tool surface, removed from the head when Pi wrote the preamble. */
165
- readonly removePiSurface: RegionRemoval;
166
- /** A relocated block (this package's or a peer's), removed from the tail. */
167
- readonly removeRelocatedSurface: RegionRemoval;
168
- /** This session's block, in this layout's shape. */
169
- readonly renderBlock: (bullets: ToolSurfaceBullets) => string;
170
- }
171
-
172
- /** A prompt's layout, and the line at which the text extensions appended begins. */
173
- interface DetectedLayout {
174
- readonly layout: PromptLayout;
175
- readonly tailStart: number;
176
- }
177
-
178
- /**
179
- * The shape Pi wrote through 0.85: `Available tools:` and `Guidelines:`
180
- * sections in its preamble, and a `Current working directory:` footer last.
181
- */
182
- const HEADER_LAYOUT: PromptLayout = {
183
- removePiSurface: removeToolSurfaceSections,
184
- removeRelocatedSurface: removeToolSurfaceSections,
185
- renderBlock: renderHeaderBlock,
186
- };
187
-
188
- /**
189
- * The shape Pi writes from 0.86: an untagged preamble, then `<name>` sections
190
- * joined by a blank line (`<tools>` and `<rules>` among them), and a `<cwd>`
191
- * section last among its own.
192
- */
193
- const SECTION_LAYOUT: PromptLayout = {
194
- removePiSurface: removePiToolSurfaceSections,
195
- removeRelocatedSurface: removeRelocatedToolSurface,
196
- renderBlock: renderSectionBlock,
197
- };
198
-
199
- /**
200
- * The prompt's layout, read off the cwd layer Pi wrote, and where the text
201
- * extensions appended after that layer begins.
202
- *
203
- * Pi writes its cwd layer last among its own, in both of its branches: a
204
- * `Current working directory:` footer through 0.85, a `<cwd>` section from
205
- * 0.86. So whichever of the two sits later is Pi's, and a line of either shape
206
- * quoted in a context file or a custom prompt is always above it. The shape is
207
- * told apart by which layer is present, never by version sniffing.
208
- *
209
- * A prompt carrying neither is read as the header layout and treated as all
210
- * head, so a block appended to *that* prompt cannot be found and replaced, and
211
- * a custom preamble would collect a second one. Pi writes a cwd layer in both
212
- * branches of both renderers, so reaching this needs a downstream rewrite of
213
- * Pi's whole output.
214
- */
215
- function detectPromptLayout(lines: readonly string[]): DetectedLayout {
216
- const footerAt = lines.findLastIndex((line) =>
217
- line.startsWith(PROMPT_FOOTER_PREFIX),
218
- );
219
- const cwdCloseAt = lastCwdSectionClose(lines);
220
- if (cwdCloseAt > footerAt) {
221
- return { layout: SECTION_LAYOUT, tailStart: cwdCloseAt + 1 };
222
- }
223
- return {
224
- layout: HEADER_LAYOUT,
225
- tailStart: footerAt === -1 ? lines.length : footerAt + 1,
226
- };
227
- }
228
-
229
- /**
230
- * Line index of the closing tag of the last `<cwd>` section, or -1 when there
231
- * is none.
232
- *
233
- * Pi renders the section as exactly three lines (the opening tag, the
234
- * directory, the closing tag), so that is the shape matched, the way the
235
- * footer is matched by its prefix.
236
- */
237
- function lastCwdSectionClose(lines: readonly string[]): number {
238
- return lines.findLastIndex(
239
- (line, index) =>
240
- line === CWD_SECTION_CLOSE && lines[index - 2] === CWD_SECTION_OPEN,
241
- );
242
- }
243
-
244
- /**
245
- * One region's surviving text: its sections removed, when they are ours to
246
- * remove, or returned as it arrived when `removal` is `null`.
247
- *
248
- * Blank runs are collapsed only where a removal opened one, so a region this
249
- * pass took nothing out of is returned exactly as it arrived rather than
250
- * reflowed by a pass that had nothing to do with it.
251
- */
252
- function settleRegion(
253
- lines: readonly string[],
254
- removal: RegionRemoval | null,
255
- ): string {
256
- if (!removal) {
257
- return lines.join("\n");
258
- }
259
- const kept = removal(lines);
260
- const text = kept.join("\n");
261
- return kept.length === lines.length ? text : collapseExtraBlankLines(text);
262
- }
263
-
264
- /**
265
- * Remove the `Available tools:` and `Guidelines:` sections, and the filler
266
- * sentence between them, from one region.
267
- *
268
- * Each section is located by its own header, so the two are removed whether
269
- * they sit adjacent in Pi's preamble or alone in the tail — including a block
270
- * this function already produced, which is what makes it safe to apply to its
271
- * own output, and what keeps it order-independent with a second writer.
272
- */
273
- function removeToolSurfaceSections(lines: readonly string[]): string[] {
274
- let remaining = [...lines];
275
- for (const header of [
276
- AVAILABLE_TOOLS_SECTION_HEADER,
277
- GUIDELINES_SECTION_HEADER,
278
- ]) {
279
- const section = findSection(remaining, header);
280
- if (section) {
281
- remaining = [
282
- ...remaining.slice(0, section.start),
283
- ...remaining.slice(section.end),
284
- ];
285
- }
286
- }
287
-
288
- return remaining.filter(
289
- (line) => !line.trimStart().startsWith(CUSTOM_TOOLS_FILLER_PREFIX),
290
- );
291
- }
292
-
293
- /**
294
- * Remove Pi 0.86+'s own `<tools>` and `<rules>` sections, tags included.
295
- *
296
- * A section is Pi's only when it opens and closes before the first section Pi
297
- * writes after it; the plain headers `removeToolSurfaceSections` matches are
298
- * never searched here, because Pi writes none on this shape and every match
299
- * would be a user's or another extension's text.
300
- */
301
- function removePiToolSurfaceSections(head: readonly string[]): string[] {
302
- return removeTaggedToolSurface(head, laterPiSectionStart);
303
- }
304
-
305
- /**
306
- * Remove a relocated block from the extension tail, in either shape.
307
- *
308
- * This package writes the tagged shape here, and a peer writer may write
309
- * either, so both are removed: the pass stays safe to apply to its own output
310
- * and order-independent with a second writer.
311
- */
312
- function removeRelocatedToolSurface(tail: readonly string[]): string[] {
313
- return removeToolSurfaceSections(
314
- removeTaggedToolSurface(tail, (lines) => lines.length),
315
- );
316
- }
317
-
318
- /**
319
- * Remove the first `<tools>` and the first `<rules>` section, each only when
320
- * it closes before the line `limitOf` names.
321
- */
322
- function removeTaggedToolSurface(
323
- lines: readonly string[],
324
- limitOf: (lines: readonly string[]) => number,
325
- ): string[] {
326
- let remaining = [...lines];
327
- for (const name of TOOL_SURFACE_SECTION_NAMES) {
328
- const section = findTaggedSection(remaining, name, limitOf(remaining));
329
- if (section) {
330
- remaining = [
331
- ...remaining.slice(0, section.start),
332
- ...remaining.slice(section.end),
333
- ];
334
- }
335
- }
336
- return remaining;
337
- }
338
-
339
- /** Line index of the first section Pi writes after its tool surface. */
340
- function laterPiSectionStart(lines: readonly string[]): number {
341
- const at = lines.findIndex((line) => LATER_PI_SECTION_OPENS.has(line));
342
- return at === -1 ? lines.length : at;
343
- }
344
-
345
- /**
346
- * The first `<name>` section, opening tag through closing tag, that ends
347
- * before `limit`; `null` when there is none.
348
- */
349
- function findTaggedSection(
350
- lines: readonly string[],
351
- name: string,
352
- limit: number,
353
- ): LineSection | null {
354
- const start = lines.indexOf(`<${name}>`);
355
- if (start === -1 || start >= limit) {
356
- return null;
357
- }
358
- const closeAt = lines.indexOf(`</${name}>`, start + 1);
359
- if (closeAt === -1 || closeAt >= limit) {
360
- return null;
361
- }
362
- return { start, end: closeAt + 1 };
363
- }
364
-
365
- /**
366
- * This session's tool surface, as Pi 0.86+ would have rendered it: a `<tools>`
367
- * section, when any allowed tool has a snippet, then a `<rules>` section.
368
- */
369
- function renderSectionBlock(bullets: ToolSurfaceBullets): string {
370
- const sections: string[] = [];
371
- if (bullets.tools.length > 0) {
372
- sections.push(taggedSection("tools", bullets.tools));
373
- }
374
- sections.push(taggedSection("rules", bullets.rules));
375
-
376
- return sections.join("\n\n");
377
- }
378
-
379
- /** A section in the form Pi 0.86+ renders: its tags on their own lines. */
380
- function taggedSection(name: string, lines: readonly string[]): string {
381
- return [`<${name}>`, ...lines, `</${name}>`].join("\n");
382
- }
383
-
384
- /** This session's tool surface, as Pi through 0.85 would have rendered it. */
385
- function renderHeaderBlock(bullets: ToolSurfaceBullets): string {
386
- const sections: string[] = [];
387
-
388
- const toolList = renderAvailableTools(bullets.tools);
389
- if (toolList) {
390
- sections.push(toolList);
391
- }
392
- sections.push(renderGuidelines(bullets.rules));
393
-
394
- return sections.join("\n\n");
395
- }
396
-
397
- /** The `Available tools:` section, or `null` when it lists no tool. */
398
- function renderAvailableTools(bullets: readonly string[]): string | null {
399
- return bullets.length > 0
400
- ? [AVAILABLE_TOOLS_SECTION_HEADER, ...bullets].join("\n")
401
- : null;
402
- }
403
-
404
- /** The `Guidelines:` section. */
405
- function renderGuidelines(bullets: readonly string[]): string {
406
- return [GUIDELINES_SECTION_HEADER, ...bullets].join("\n");
407
- }
408
-
409
- /** The bullets a tool-surface block wraps, whatever shape wraps them. */
410
- interface ToolSurfaceBullets {
411
- /** `- name: snippet` lines; empty when no allowed tool has a snippet. */
412
- readonly tools: readonly string[];
413
- /** `- rule` lines, in `buildSystemPrompt`'s order. */
414
- readonly rules: readonly string[];
415
- }
416
-
417
- function toolSurfaceBullets(inputs: ToolSurfaceInputs): ToolSurfaceBullets {
418
- return { tools: toolBullets(inputs), rules: ruleBullets(inputs) };
63
+ ): ToolSurfaceSections {
64
+ const tools = toolBullets(inputs);
65
+ const rules = ruleBullets(inputs).join("\n");
66
+ return tools.length > 0 ? { tools: tools.join("\n"), rules } : { rules };
419
67
  }
420
68
 
421
69
  /**
@@ -436,7 +84,8 @@ function toolBullets(inputs: ToolSurfaceInputs): string[] {
436
84
  *
437
85
  * Mirrors `buildSystemPrompt`'s assembly: its conditional file-exploration
438
86
  * bullet first, then each allowed tool's own contributions, then the bullets
439
- * other extensions added, then its two unconditional bullets — de-duplicated in first-seen order, as Pi does.
87
+ * other extensions added, then its two unconditional bullets — de-duplicated
88
+ * in first-seen order, as Pi does.
440
89
  */
441
90
  function ruleBullets(inputs: ToolSurfaceInputs): string[] {
442
91
  const bullets: string[] = [];
@@ -512,48 +161,3 @@ function fileExplorationGuideline(
512
161
  }
513
162
  return "Use bash for file operations like ls, rg, find";
514
163
  }
515
-
516
- function normalizePrompt(prompt: string): string {
517
- return (prompt || "").replace(/\r\n/g, "\n");
518
- }
519
-
520
- function collapseExtraBlankLines(text: string): string {
521
- return text.replace(/\n{3,}/g, "\n\n").trimEnd();
522
- }
523
-
524
- /**
525
- * Whether the line belongs to the body of the section above it.
526
- *
527
- * Pi writes a section as its header, then bullets — or the `(none)` placeholder
528
- * when the list is empty — and separates it from what follows with a blank
529
- * line. Anything else is already outside the section, however it is punctuated:
530
- * a section that ran on to "the next line ending in a colon" swallowed the
531
- * prose in between, which is somebody else's text whenever the match was not
532
- * Pi's own (#919, #932).
533
- */
534
- function isSectionBodyLine(line: string): boolean {
535
- const trimmed = line.trim();
536
- if (trimmed.length === 0) return true; // blank line
537
- if (trimmed.startsWith("- ")) return true; // bullet
538
- if (trimmed === EMPTY_LIST_PLACEHOLDER) return true; // Pi's empty list
539
- if (line !== line.trimStart()) return true; // indented
540
- return false;
541
- }
542
-
543
- /** The header line plus its own body, or `null` when the header is absent. */
544
- function findSection(
545
- lines: readonly string[],
546
- header: string,
547
- ): LineSection | null {
548
- const start = lines.findIndex((line) => line.trim() === header);
549
- if (start === -1) {
550
- return null;
551
- }
552
-
553
- let end = start + 1;
554
- while (end < lines.length && isSectionBodyLine(lines[end])) {
555
- end += 1;
556
- }
557
-
558
- return { start, end };
559
- }