@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 +35 -0
- package/package.json +23 -0
- package/pi-context-include.ts +187 -0
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
|
+
}
|