@d3ara1n/pi-context-include 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.
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # @d3ara1n/pi-context-include
2
+
3
+ `@path` syntax for AGENTS.md — include files by reference.
4
+
5
+ ## Features
6
+
7
+ - **Relative paths**: `@CODEGRAPH.md`, `@./docs/rules.md`, `@../shared/AGENTS.md`
8
+ - **Absolute paths**: `@/absolute/path/to/file.md`
9
+ - **Home directory**: `@~/.agents/CODEGRAPH.md`
10
+ - **Recursive includes**: included files can themselves contain `@` references
11
+ - **Cycle detection**: prevents infinite include loops
12
+ - **Size guard**: 500KB total limit, 10 levels deep
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pi install npm:@d3ara1n/pi-context-include
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ In any AGENTS.md file:
23
+
24
+ ```markdown
25
+ # Project Rules
26
+
27
+ @./docs/api-conventions.md
28
+ @~/.agents/CODEGRAPH.md
29
+ ```
30
+
31
+ On each turn, the extension reads the referenced files and injects their content into the system prompt.
32
+
33
+ ## Supported file types
34
+
35
+ `.md`, `.txt`, `.yaml`, `.yml`, `.json`, `.toml`
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@d3ara1n/pi-context-include",
3
+ "version": "1.0.0",
4
+ "description": "@path syntax for AGENTS.md — include files by reference with recursive resolution",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "main": "pi-context-include.ts",
9
+ "peerDependencies": {
10
+ "@earendil-works/pi-coding-agent": "*"
11
+ },
12
+ "pi": {
13
+ "extensions": [
14
+ "./pi-context-include.ts"
15
+ ]
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/d3ara1n/pi-extensions",
20
+ "directory": "packages/pi-context-include"
21
+ },
22
+ "license": "MIT"
23
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Context Include Extension (@-syntax for AGENTS.md)
3
+ *
4
+ * Enables `@path/to/file.md` references in AGENTS.md files.
5
+ * On session start, scans all loaded AGENTS.md files for `@path` patterns,
6
+ * reads the referenced files, and injects their content into the system prompt.
7
+ *
8
+ * Supports:
9
+ * - Relative paths (resolved relative to the AGENTS.md file that contains the reference)
10
+ * - Absolute paths
11
+ * - Multiple `@` references per file
12
+ * - Recursive includes (an included file can itself contain `@` references)
13
+ * - Cycle detection to prevent infinite recursion
14
+ *
15
+ * Syntax: A line containing only `@path/to/file.md` or with leading text
16
+ * that ends with whitespace before `@path`. The `@` must be followed by
17
+ * a path (relative or absolute) to a file.
18
+ *
19
+ * Example AGENTS.md:
20
+ * ```
21
+ * # My Project Rules
22
+ * @CODEGRAPH.md
23
+ * @docs/api-conventions.md
24
+ * ```
25
+ *
26
+ * Place in ~/.pi/agent/extensions/ (global) or .pi/extensions/ (project-local).
27
+ */
28
+
29
+ import * as fs from "node:fs";
30
+ import * as os from "node:os";
31
+ import * as path from "node:path";
32
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
33
+
34
+ const MAX_INCLUDE_DEPTH = 10;
35
+ const MAX_INCLUDED_BYTES = 500_000; // 500KB total limit for all includes
36
+
37
+ interface IncludedFile {
38
+ path: string;
39
+ source: string; // which AGENTS.md referenced it
40
+ content: string;
41
+ }
42
+
43
+ /**
44
+ * Extract @path references from a file's content.
45
+ * Matches lines containing only `@path/to/file.md` (the entire trimmed line
46
+ * starts with @) or inline `@path` references.
47
+ *
48
+ * Supported path patterns:
49
+ * - @file.md (bare filename)
50
+ * - @./relative.md (explicit relative)
51
+ * - @../parent.md (parent relative)
52
+ * - @/absolute/path.md (absolute)
53
+ * - @path/to/file.md (multi-segment relative)
54
+ */
55
+ function extractReferences(content: string): string[] {
56
+ const refs: string[] = [];
57
+ const seen = new Set<string>();
58
+ const lines = content.split("\n");
59
+
60
+ // Pattern: @ followed by a path that ends with a known extension
61
+ // Path chars: alphanumeric, dots, dashes, underscores, slashes, backslashes
62
+ // The path must end with a file extension
63
+ const refPattern = /@([~.]?[\w./\\-]+\.(?:md|txt|yaml|yml|json|toml))(?:\s|$)/g;
64
+
65
+ for (const line of lines) {
66
+ const trimmed = line.trim();
67
+ let match: RegExpExecArray | null;
68
+ refPattern.lastIndex = 0;
69
+
70
+ while ((match = refPattern.exec(trimmed)) !== null) {
71
+ const ref = match[1];
72
+ if (!seen.has(ref)) {
73
+ seen.add(ref);
74
+ refs.push(ref);
75
+ }
76
+ }
77
+ }
78
+
79
+ return refs;
80
+ }
81
+
82
+ /**
83
+ * Recursively resolve @path references from a file.
84
+ */
85
+ function resolveIncludes(
86
+ filePath: string,
87
+ content: string,
88
+ visited: Set<string>,
89
+ depth: number,
90
+ results: IncludedFile[],
91
+ ): void {
92
+ if (depth > MAX_INCLUDE_DEPTH) return;
93
+
94
+ const canonical = path.resolve(filePath);
95
+ if (visited.has(canonical)) return; // cycle detection
96
+ visited.add(canonical);
97
+
98
+ const refs = extractReferences(content);
99
+ const baseDir = path.dirname(canonical);
100
+
101
+ for (const ref of refs) {
102
+ // Expand ~ to home directory
103
+ let expandedRef = ref;
104
+ if (expandedRef.startsWith("~")) {
105
+ expandedRef = path.join(os.homedir(), expandedRef.slice(1));
106
+ }
107
+
108
+ const resolved = path.isAbsolute(expandedRef) ? expandedRef : path.resolve(baseDir, expandedRef);
109
+ const resolvedCanonical = path.resolve(resolved);
110
+
111
+ if (visited.has(resolvedCanonical)) continue; // already included or cycle
112
+ if (!fs.existsSync(resolvedCanonical)) {
113
+ console.warn(`[pi-context-include] Referenced file not found: ${ref} (resolved to ${resolvedCanonical})`);
114
+ continue;
115
+ }
116
+
117
+ try {
118
+ const includedContent = fs.readFileSync(resolvedCanonical, "utf-8");
119
+ results.push({
120
+ path: resolvedCanonical,
121
+ source: canonical,
122
+ content: includedContent,
123
+ });
124
+
125
+ // Recurse into included file for nested @references
126
+ resolveIncludes(resolvedCanonical, includedContent, visited, depth + 1, results);
127
+ } catch (err) {
128
+ console.warn(`[pi-context-include] Failed to read ${resolvedCanonical}:`, err);
129
+ }
130
+ }
131
+ }
132
+
133
+ export default function contextIncludeExtension(pi: ExtensionAPI) {
134
+ pi.on("before_agent_start", async (event) => {
135
+ const { systemPrompt, systemPromptOptions } = event;
136
+ const contextFiles = systemPromptOptions.contextFiles;
137
+
138
+ if (!contextFiles || contextFiles.length === 0) {
139
+ return;
140
+ }
141
+
142
+ const allIncluded: IncludedFile[] = [];
143
+ const visited = new Set<string>();
144
+
145
+ for (const ctxFile of contextFiles) {
146
+ // contextFiles are paths to AGENTS.md files
147
+ const filePath = typeof ctxFile === "string" ? ctxFile : ctxFile.path;
148
+ if (!filePath || !fs.existsSync(filePath)) continue;
149
+
150
+ try {
151
+ const content = fs.readFileSync(filePath, "utf-8");
152
+ resolveIncludes(filePath, content, visited, 0, allIncluded);
153
+ } catch (err) {
154
+ console.warn(`[pi-context-include] Failed to read context file ${filePath}:`, err);
155
+ }
156
+ }
157
+
158
+ if (allIncluded.length === 0) {
159
+ return;
160
+ }
161
+
162
+ // Build the injected content with size limit
163
+ let totalBytes = 0;
164
+ const sections: string[] = [];
165
+
166
+ for (const inc of allIncluded) {
167
+ if (totalBytes + inc.content.length > MAX_INCLUDED_BYTES) {
168
+ console.warn(
169
+ `[pi-context-include] Skipping ${inc.path}: total included size would exceed ${MAX_INCLUDED_BYTES} bytes`,
170
+ );
171
+ continue;
172
+ }
173
+
174
+ const relativePath = path.relative(process.cwd(), inc.path) || inc.path;
175
+ sections.push(`--- Begin included: ${relativePath} ---\n${inc.content}\n--- End included: ${relativePath} ---`);
176
+ totalBytes += inc.content.length;
177
+ }
178
+
179
+ if (sections.length === 0) return;
180
+
181
+ const injected = `\n\n## Included Files (via @-syntax)\n\n${sections.join("\n\n")}\n`;
182
+
183
+ return {
184
+ systemPrompt: systemPrompt + injected,
185
+ };
186
+ });
187
+ }