pi-context-filter 1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -0
  3. package/index.ts +229 -0
  4. package/package.json +20 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HazAT
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # Context Filter
2
+
3
+ A pi extension that gives you `.gitignore`-style control over which context files, agent instructions, and skills are included in the system prompt.
4
+
5
+ ## Why?
6
+
7
+ Pi automatically loads `AGENTS.md` / `CLAUDE.md` files from your home directory, ancestor directories, and the current project. It also loads all discovered skills into the prompt. Sometimes you want to override this — exclude a parent project's instructions when working in a subfolder, skip the global `AGENTS.md`, or inject your own context files.
8
+
9
+ ## Setup
10
+
11
+ Place a `.context` file in your project's `.pi/` directory:
12
+
13
+ ```
14
+ your-project/
15
+ ├── .pi/
16
+ │ └── .context
17
+ └── ...
18
+ ```
19
+
20
+ Pi reads this file on session start. Use `/reload` to pick up changes without restarting.
21
+
22
+ ## Syntax
23
+
24
+ ```bash
25
+ # Lines starting with # are comments
26
+ # Blank lines are ignored
27
+
28
+ # Exclude files with ! (supports globs)
29
+ !~/.pi/agent/AGENTS.md
30
+
31
+ # Include files with + (resolved relative to project root)
32
+ +.pi/MYCONTEXT.md
33
+ ```
34
+
35
+ ### Exclusions (`!`)
36
+
37
+ Exclude `AGENTS.md` / `CLAUDE.md` sections and skill entries from the system prompt. Patterns are matched against absolute file paths using [picomatch](https://github.com/micromatch/picomatch) glob syntax.
38
+
39
+ ```bash
40
+ # Exact path (~ expands to home directory)
41
+ !~/.pi/agent/AGENTS.md
42
+
43
+ # All AGENTS.md files from any ancestor directory
44
+ !**/AGENTS.md
45
+
46
+ # A specific skill
47
+ !**/skills/brainstorm/SKILL.md
48
+
49
+ # All skills under a directory
50
+ !**/skills/**
51
+ ```
52
+
53
+ ### Inclusions (`+`)
54
+
55
+ Add arbitrary files to the system prompt. Paths are resolved relative to the project root. Files are read fresh on every prompt, so edits are picked up immediately.
56
+
57
+ ```bash
58
+ # Add a custom context file
59
+ +.pi/MYCONTEXT.md
60
+
61
+ # Absolute paths work too
62
+ +~/shared-context/team-guidelines.md
63
+ ```
64
+
65
+ ## Examples
66
+
67
+ **Use only the local project's AGENTS.md, ignore everything else:**
68
+
69
+ ```bash
70
+ # .pi/.context
71
+ !~/.pi/agent/AGENTS.md
72
+ !../**/AGENTS.md
73
+ ```
74
+
75
+ **Swap in your own context file:**
76
+
77
+ ```bash
78
+ # .pi/.context
79
+ !**/AGENTS.md
80
+ +.pi/my-instructions.md
81
+ ```
82
+
83
+ **Keep everything but drop a noisy skill:**
84
+
85
+ ```bash
86
+ # .pi/.context
87
+ !**/skills/brainstorm/SKILL.md
88
+ ```
89
+
90
+ ## How It Works
91
+
92
+ The extension hooks into pi's `before_agent_start` event to modify the system prompt before each LLM call:
93
+
94
+ 1. **Exclusions** — Strips `## /path/to/AGENTS.md` sections from the Project Context block and removes matching `<skill>` entries from the available skills list.
95
+ 2. **Inclusions** — Reads the specified files and appends them to the Project Context section.
96
+
97
+ On startup, a widget above the editor shows all active rules (auto-hides after 15 seconds), and a persistent footer status displays the filter summary (e.g., `[.context] ✕ 5 excluded`).
98
+
99
+ Note: Pi's own `[Context]` and `[Skills]` startup display still shows all discovered files — that's pi's internal display and can't be changed by extensions. The widget and footer make it clear what's actually being filtered from the prompt.
100
+
101
+ ## Limitations
102
+
103
+ - **Extensions cannot be filtered.** Extension loading happens before any extension code runs, so this tool can only control what appears in the *system prompt* (AGENTS.md content and skill listings).
104
+ - **Format-dependent.** The extension parses the system prompt structure that pi generates. If pi significantly changes its prompt format in the future, the filtering may need updating.
package/index.ts ADDED
@@ -0,0 +1,229 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { readFileSync, existsSync } from "node:fs";
3
+ import { resolve, join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import picomatch from "picomatch";
6
+
7
+ interface ContextRules {
8
+ exclusionPatterns: string[]; // original patterns for display
9
+ exclusions: picomatch.Matcher[];
10
+ inclusions: string[]; // resolved absolute paths
11
+ inclusionPatterns: string[]; // original patterns for display
12
+ }
13
+
14
+ function expandTilde(p: string): string {
15
+ if (p === "~") return homedir();
16
+ if (p.startsWith("~/")) return join(homedir(), p.slice(2));
17
+ return p;
18
+ }
19
+
20
+ function parseContextFile(filePath: string, cwd: string): ContextRules {
21
+ const rules: ContextRules = {
22
+ exclusionPatterns: [],
23
+ exclusions: [],
24
+ inclusions: [],
25
+ inclusionPatterns: [],
26
+ };
27
+
28
+ if (!existsSync(filePath)) return rules;
29
+
30
+ const content = readFileSync(filePath, "utf-8");
31
+ for (const rawLine of content.split("\n")) {
32
+ const line = rawLine.trim();
33
+ if (!line || line.startsWith("#")) continue;
34
+
35
+ if (line.startsWith("!")) {
36
+ const raw = line.slice(1).trim();
37
+ const pattern = expandTilde(raw);
38
+ rules.exclusionPatterns.push(raw);
39
+ rules.exclusions.push(picomatch(pattern, { dot: true }));
40
+ } else if (line.startsWith("+")) {
41
+ const raw = line.slice(1).trim();
42
+ const rawPath = expandTilde(raw);
43
+ rules.inclusionPatterns.push(raw);
44
+ rules.inclusions.push(resolve(cwd, rawPath));
45
+ }
46
+ }
47
+
48
+ return rules;
49
+ }
50
+
51
+ function isExcluded(filePath: string, matchers: picomatch.Matcher[]): boolean {
52
+ return matchers.some((m) => m(filePath));
53
+ }
54
+
55
+ /**
56
+ * Remove ## path sections from the "# Project Context" area of the system prompt.
57
+ * Sections look like: ## /absolute/path/to/AGENTS.md\n\n<content>\n\n
58
+ */
59
+ function filterContextSections(prompt: string, matchers: picomatch.Matcher[]): string {
60
+ if (matchers.length === 0) return prompt;
61
+
62
+ // Match sections: ## <path>\n\n<content up to next ## or end of Project Context>
63
+ // The Project Context block starts with "# Project Context"
64
+ const projectCtxStart = prompt.indexOf("# Project Context");
65
+ if (projectCtxStart === -1) return prompt;
66
+
67
+ // Find where Project Context ends (next top-level heading or skills section)
68
+ const afterCtx = prompt.indexOf("\n\nThe following skills", projectCtxStart);
69
+ const afterDate = prompt.indexOf("\nCurrent date and time:", projectCtxStart);
70
+
71
+ let projectCtxEnd: number;
72
+ if (afterCtx !== -1) {
73
+ projectCtxEnd = afterCtx;
74
+ } else if (afterDate !== -1) {
75
+ projectCtxEnd = afterDate;
76
+ } else {
77
+ projectCtxEnd = prompt.length;
78
+ }
79
+
80
+ const before = prompt.slice(0, projectCtxStart);
81
+ const contextBlock = prompt.slice(projectCtxStart, projectCtxEnd);
82
+ const after = prompt.slice(projectCtxEnd);
83
+
84
+ // Split context block into sections by ## /path headers only.
85
+ // AGENTS.md content contains its own ## headings, so we must only match
86
+ // headers that look like file paths (start with /).
87
+ const sectionRegex = /^## (\/[^\n]+)$/gm;
88
+ const sections: { path: string; start: number; end: number }[] = [];
89
+ let match: RegExpExecArray | null;
90
+
91
+ while ((match = sectionRegex.exec(contextBlock)) !== null) {
92
+ if (sections.length > 0) {
93
+ sections[sections.length - 1].end = match.index;
94
+ }
95
+ sections.push({ path: match[1].trim(), start: match.index, end: contextBlock.length });
96
+ }
97
+
98
+ // Build filtered context block
99
+ let filtered = contextBlock;
100
+ // Process in reverse to preserve indices
101
+ for (let i = sections.length - 1; i >= 0; i--) {
102
+ const section = sections[i];
103
+ if (isExcluded(section.path, matchers)) {
104
+ filtered = filtered.slice(0, section.start) + filtered.slice(section.end);
105
+ }
106
+ }
107
+
108
+ // If all sections were removed, remove the entire Project Context header too
109
+ const hasAnySections = /^## /m.test(filtered);
110
+ if (!hasAnySections) {
111
+ return before.trimEnd() + after;
112
+ }
113
+
114
+ return before + filtered + after;
115
+ }
116
+
117
+ /**
118
+ * Remove <skill> blocks whose <location> matches an exclusion pattern.
119
+ */
120
+ function filterSkillBlocks(prompt: string, matchers: picomatch.Matcher[]): string {
121
+ if (matchers.length === 0) return prompt;
122
+
123
+ return prompt.replace(
124
+ / <skill>\n(?:.*\n)*? <\/skill>/g,
125
+ (block) => {
126
+ const locMatch = block.match(/<location>([^<]+)<\/location>/);
127
+ if (locMatch && isExcluded(locMatch[1], matchers)) {
128
+ return "";
129
+ }
130
+ return block;
131
+ }
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Append included files to the system prompt in the Project Context section.
137
+ */
138
+ function appendInclusions(
139
+ prompt: string,
140
+ inclusions: string[],
141
+ notify: (msg: string) => void
142
+ ): string {
143
+ if (inclusions.length === 0) return prompt;
144
+
145
+ let additions = "";
146
+ for (const filePath of inclusions) {
147
+ if (!existsSync(filePath)) {
148
+ notify(`context-filter: missing inclusion file: ${filePath}`);
149
+ continue;
150
+ }
151
+ try {
152
+ const content = readFileSync(filePath, "utf-8");
153
+ additions += `## ${filePath}\n\n${content}\n\n`;
154
+ } catch (e) {
155
+ notify(`context-filter: failed to read ${filePath}: ${e}`);
156
+ }
157
+ }
158
+
159
+ if (!additions) return prompt;
160
+
161
+ // Insert before skills section or date line
162
+ const skillsIdx = prompt.indexOf("\n\nThe following skills");
163
+ const dateIdx = prompt.indexOf("\nCurrent date and time:");
164
+ const insertIdx = skillsIdx !== -1 ? skillsIdx : dateIdx !== -1 ? dateIdx : prompt.length;
165
+
166
+ // Ensure there's a Project Context section
167
+ const hasProjectCtx = prompt.includes("# Project Context");
168
+ if (!hasProjectCtx) {
169
+ additions = "\n\n# Project Context\n\nProject-specific instructions and guidelines:\n\n" + additions;
170
+ }
171
+
172
+ return prompt.slice(0, insertIdx) + additions + prompt.slice(insertIdx);
173
+ }
174
+
175
+ export default function (pi: ExtensionAPI) {
176
+ let rules: ContextRules = { exclusions: [], inclusions: [] };
177
+ let cwd = process.cwd();
178
+
179
+ pi.on("session_start", async (_event, ctx) => {
180
+ cwd = ctx.cwd;
181
+ const contextPath = join(cwd, ".pi", ".context");
182
+ rules = parseContextFile(contextPath, cwd);
183
+
184
+ if (!ctx.hasUI) return;
185
+ if (rules.exclusions.length === 0 && rules.inclusions.length === 0) return;
186
+
187
+ // Show summary in footer status
188
+ const parts: string[] = [];
189
+ if (rules.exclusionPatterns.length > 0) {
190
+ parts.push(`✕ ${rules.exclusionPatterns.length} excluded`);
191
+ }
192
+ if (rules.inclusionPatterns.length > 0) {
193
+ parts.push(`✓ ${rules.inclusionPatterns.length} included`);
194
+ }
195
+ ctx.ui.setStatus("context-filter", `[.context] ${parts.join(", ")}`);
196
+
197
+ // Show detailed widget above editor
198
+ const lines: string[] = ["[Context Filter]"];
199
+ for (const p of rules.exclusionPatterns) {
200
+ lines.push(` ✕ ${p}`);
201
+ }
202
+ for (const p of rules.inclusionPatterns) {
203
+ lines.push(` ✓ ${p}`);
204
+ }
205
+ ctx.ui.setWidget("context-filter", lines);
206
+
207
+ // Auto-hide widget after 15 seconds, keep status
208
+ setTimeout(() => {
209
+ ctx.ui.setWidget("context-filter", undefined);
210
+ }, 15_000);
211
+ });
212
+
213
+ pi.on("before_agent_start", async (event, ctx) => {
214
+ if (rules.exclusions.length === 0 && rules.inclusions.length === 0) return;
215
+
216
+ let prompt = event.systemPrompt;
217
+
218
+ // Apply exclusions
219
+ prompt = filterContextSections(prompt, rules.exclusions);
220
+ prompt = filterSkillBlocks(prompt, rules.exclusions);
221
+
222
+ // Apply inclusions
223
+ prompt = appendInclusions(prompt, rules.inclusions, (msg) => {
224
+ if (ctx.hasUI) ctx.ui.notify(msg, "warning");
225
+ });
226
+
227
+ return { systemPrompt: prompt };
228
+ });
229
+ }
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "pi-context-filter",
3
+ "version": "1.0.0",
4
+ "description": "A pi extension that provides .gitignore-style control over which context files and skills appear in the system prompt",
5
+ "keywords": ["pi-package"],
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/HazAT/pi-context-filter"
9
+ },
10
+ "license": "MIT",
11
+ "pi": {
12
+ "extensions": ["./index.ts"]
13
+ },
14
+ "dependencies": {
15
+ "picomatch": "^4.0.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/picomatch": "^3.0.0"
19
+ }
20
+ }