feat(plugins): Add _promptinclude plugin for persistent system prompt files
linuztx committed
Mar 13, 2026 at 22:49 UTC
785cf33921f32727c8dedaf0202a2fb650376944
7 files changed
+400
plugins/_promptinclude/default_config.yaml
new
+18
@@ -0,0 +1,18 @@
1
+name_pattern: "*.promptinclude.md"
2
+max_depth: 10
3
+max_file_tokens: 2000
4
+max_file_count: 50
5
+max_total_tokens: 8000
6
+gitignore: |
7
+ venv/**
8
+ **/__pycache__/**
9
+ **/node_modules/**
10
+ **/.npm/**
11
+ **/.git/**
12
+ **/.conda/**
13
+ **/.cache/**
14
+ **/dist/**
15
+ **/build/**
16
+ **/.tox/**
17
+ **/.eggs/**
18
+ **/*.egg-info/**
plugins/_promptinclude/extensions/python/system_prompt/_16_promptinclude.py
new
+89
@@ -0,0 +1,89 @@
1
+from helpers.extension import Extension
2
+from helpers import plugins, files, runtime
3
+from helpers import projects
4
+from helpers.settings import get_settings
5
+from agent import Agent, LoopData
6
+
7
+from plugins._promptinclude.helpers.scanner import scan_promptinclude_files, ScanResult
8
+
9
+
10
+class PromptInclude(Extension):
11
+
12
+ async def execute(
13
+ self,
14
+ system_prompt: list[str] = [],
15
+ loop_data: LoopData = LoopData(),
16
+ **kwargs,
17
+ ):
18
+ if not self.agent:
19
+ return
20
+
21
+ config = plugins.get_plugin_config("_promptinclude", agent=self.agent) or {}
22
+ scan_path = _resolve_workdir(self.agent)
23
+
24
+ if not scan_path:
25
+ return
26
+
27
+ name_pattern = config.get("name_pattern", "*.promptinclude.md")
28
+ result = await runtime.call_development_function(
29
+ scan_promptinclude_files,
30
+ scan_path,
31
+ name_pattern=name_pattern,
32
+ max_depth=config.get("max_depth", 10),
33
+ max_file_tokens=config.get("max_file_tokens", 2000),
34
+ max_file_count=config.get("max_file_count", 50),
35
+ max_total_tokens=config.get("max_total_tokens", 8000),
36
+ gitignore=config.get("gitignore", ""),
37
+ )
38
+
39
+ if not result["files"] and result["skipped_count"] == 0:
40
+ prompt = self.agent.read_prompt(
41
+ "agent.system.promptinclude.md",
42
+ name_pattern=name_pattern,
43
+ includes="",
44
+ )
45
+ system_prompt.append(prompt)
46
+ return
47
+
48
+ includes = _format_includes(result)
49
+ prompt = self.agent.read_prompt(
50
+ "agent.system.promptinclude.md",
51
+ name_pattern=name_pattern,
52
+ includes=includes,
53
+ )
54
+ system_prompt.append(prompt)
55
+
56
+
57
+def _resolve_workdir(agent: Agent) -> str:
58
+ project_name = projects.get_context_project_name(agent.context)
59
+ if project_name:
60
+ folder = projects.get_project_folder(project_name)
61
+ if runtime.is_development():
62
+ folder = files.normalize_a0_path(folder)
63
+ return folder
64
+ return get_settings()["workdir_path"]
65
+
66
+
67
+def _format_includes(result: ScanResult) -> str:
68
+ lines: list[str] = []
69
+
70
+ for entry in result["files"]:
71
+ path = entry["path"]
72
+ status = entry["status"]
73
+ content = entry["content"]
74
+
75
+ if status == "skipped":
76
+ lines.append(f"{path} !!! skipped to fit")
77
+ continue
78
+
79
+ suffix = " !!! cropped to fit" if status == "cropped" else ""
80
+ lines.append(f"{path}{suffix}")
81
+ lines.append("```")
82
+ lines.append(content)
83
+ lines.append("```")
84
+ lines.append("")
85
+
86
+ if result["skipped_count"] > 0:
87
+ lines.append(f"!!! {result['skipped_count']} more files skipped to fit")
88
+
89
+ return "\n".join(lines)
plugins/_promptinclude/helpers/__init__.py
plugins/_promptinclude/helpers/scanner.py
new
+177
@@ -0,0 +1,177 @@
1
+"""Scan workdir for promptinclude files. No agent/tool dependencies."""
2
+
3
+import fnmatch
4
+import os
5
+from typing import Literal, TypedDict
6
+
7
+from pathspec import PathSpec
8
+
9
+from helpers import tokens
10
+
11
+
12
+# ------------------------------------------------------------------
13
+# Types
14
+# ------------------------------------------------------------------
15
+
16
+class FileEntry(TypedDict):
17
+ path: str
18
+ content: str
19
+ token_count: int
20
+ status: Literal["ok", "cropped", "skipped"]
21
+
22
+
23
+class ScanResult(TypedDict):
24
+ files: list[FileEntry]
25
+ skipped_count: int
26
+
27
+
28
+# ------------------------------------------------------------------
29
+# Public API
30
+# ------------------------------------------------------------------
31
+
32
+def scan_promptinclude_files(
33
+ root: str,
34
+ *,
35
+ name_pattern: str = "*.promptinclude.md",
36
+ max_depth: int = 10,
37
+ max_file_tokens: int = 2000,
38
+ max_file_count: int = 50,
39
+ max_total_tokens: int = 8000,
40
+ gitignore: str = "",
41
+) -> ScanResult:
42
+ ignore_spec = _build_ignore_spec(gitignore)
43
+ matched = _find_matching_files(root, name_pattern, max_depth, ignore_spec)
44
+ matched.sort()
45
+
46
+ result_files: list[FileEntry] = []
47
+ total_tokens_used = 0
48
+ skipped_count = 0
49
+ budget_exhausted = False
50
+
51
+ for path in matched:
52
+ if budget_exhausted or len(result_files) >= max_file_count:
53
+ skipped_count += 1
54
+ continue
55
+
56
+ try:
57
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
58
+ raw = f.read()
59
+ except (OSError, IOError):
60
+ skipped_count += 1
61
+ continue
62
+
63
+ if not raw.strip():
64
+ continue
65
+
66
+ file_tokens = tokens.count_tokens(raw)
67
+
68
+ # check if adding path line alone exceeds budget
69
+ path_tokens = tokens.count_tokens(path) + 5 # overhead for formatting
70
+ if total_tokens_used + path_tokens > max_total_tokens:
71
+ skipped_count += 1
72
+ budget_exhausted = True
73
+ continue
74
+
75
+ # per-file token cap
76
+ capped = min(file_tokens, max_file_tokens)
77
+
78
+ if total_tokens_used + path_tokens + capped > max_total_tokens:
79
+ # try to fit partial
80
+ remaining = max_total_tokens - total_tokens_used - path_tokens
81
+ if remaining > 50:
82
+ trimmed = tokens.trim_to_tokens(raw, remaining, direction="start")
83
+ trimmed_count = tokens.count_tokens(trimmed)
84
+ total_tokens_used += path_tokens + trimmed_count
85
+ result_files.append(FileEntry(
86
+ path=path, content=trimmed,
87
+ token_count=trimmed_count, status="cropped",
88
+ ))
89
+ else:
90
+ result_files.append(FileEntry(
91
+ path=path, content="",
92
+ token_count=0, status="skipped",
93
+ ))
94
+ budget_exhausted = True
95
+ continue
96
+
97
+ if capped < file_tokens:
98
+ trimmed = tokens.trim_to_tokens(raw, max_file_tokens, direction="start")
99
+ trimmed_count = tokens.count_tokens(trimmed)
100
+ total_tokens_used += path_tokens + trimmed_count
101
+ result_files.append(FileEntry(
102
+ path=path, content=trimmed,
103
+ token_count=trimmed_count, status="cropped",
104
+ ))
105
+ else:
106
+ total_tokens_used += path_tokens + file_tokens
107
+ result_files.append(FileEntry(
108
+ path=path, content=raw,
109
+ token_count=file_tokens, status="ok",
110
+ ))
111
+
112
+ # remaining unprocessed files from matched list
113
+ remaining_unprocessed = len(matched) - len(result_files) - skipped_count
114
+ if remaining_unprocessed > 0:
115
+ skipped_count += remaining_unprocessed
116
+
117
+ return ScanResult(files=result_files, skipped_count=skipped_count)
118
+
119
+
120
+# ------------------------------------------------------------------
121
+# Internal helpers
122
+# ------------------------------------------------------------------
123
+
124
+def _build_ignore_spec(gitignore: str) -> PathSpec | None:
125
+ if not gitignore or not gitignore.strip():
126
+ return None
127
+ lines = [
128
+ line.strip()
129
+ for line in gitignore.splitlines()
130
+ if line.strip() and not line.strip().startswith("#")
131
+ ]
132
+ if not lines:
133
+ return None
134
+ return PathSpec.from_lines("gitwildmatch", lines)
135
+
136
+
137
+def _find_matching_files(
138
+ root: str,
139
+ name_pattern: str,
140
+ max_depth: int,
141
+ ignore_spec: PathSpec | None,
142
+) -> list[str]:
143
+ root = os.path.abspath(root)
144
+ if not os.path.isdir(root):
145
+ return []
146
+
147
+ results: list[str] = []
148
+
149
+ for dirpath, dirnames, filenames in os.walk(root, topdown=True):
150
+ depth = dirpath[len(root):].count(os.sep)
151
+ if depth >= max_depth:
152
+ dirnames.clear()
153
+ continue
154
+
155
+ # filter ignored dirs in-place
156
+ if ignore_spec:
157
+ filtered_dirs = []
158
+ for d in dirnames:
159
+ rel = os.path.relpath(os.path.join(dirpath, d), root)
160
+ rel_posix = rel.replace(os.sep, "/")
161
+ if ignore_spec.match_file(rel_posix) or ignore_spec.match_file(f"{rel_posix}/"):
162
+ continue
163
+ filtered_dirs.append(d)
164
+ dirnames[:] = filtered_dirs
165
+
166
+ for fname in filenames:
167
+ if not fnmatch.fnmatch(fname, name_pattern):
168
+ continue
169
+ full = os.path.join(dirpath, fname)
170
+ if ignore_spec:
171
+ rel = os.path.relpath(full, root)
172
+ rel_posix = rel.replace(os.sep, "/")
173
+ if ignore_spec.match_file(rel_posix):
174
+ continue
175
+ results.append(full)
176
+
177
+ return results
plugins/_promptinclude/plugin.yaml
new
+8
@@ -0,0 +1,8 @@
1
+name: _promptinclude
2
+title: Prompt Include
3
+description: Include *.promptinclude.md files from workdir in agent system prompt.
4
+version: 1.0.0
5
+settings_sections:
6
+ - agent
7
+per_project_config: true
8
+per_agent_config: true
plugins/_promptinclude/prompts/agent.system.promptinclude.md
new
+11
@@ -0,0 +1,11 @@
1
+## system prompt includes
2
+create files matching "{{name_pattern}}" pattern in workdir, always present in system prompt
3
+use for: persistent instructions, behaviour rules, project context, reminders, style guides
4
+files searched recursively in workdir, sorted alphabetically by full path
5
+create/update/delete these files when user wants to change agent behaviour, remember instructions, or set persistent rules
6
+{{if includes}}
7
+
8
+### includes
9
+
10
+{{includes}}
11
+{{endif}}
plugins/_promptinclude/webui/config.html
new
+97
@@ -0,0 +1,97 @@
1
+<html>
2
+<head>
3
+ <title>Prompt Include</title>
4
+</head>
5
+
6
+<body>
7
+ <div x-data>
8
+ <template x-if="config">
9
+ <div>
10
+ <div class="section-title">Prompt Include</div>
11
+ <div class="section-description">
12
+ Include matching files from workdir in agent system prompt.
13
+ </div>
14
+
15
+ <div class="field">
16
+ <div class="field-label">
17
+ <div class="field-title">Name pattern</div>
18
+ <div class="field-description">
19
+ Glob pattern for files to include (e.g. *.promptinclude.md).
20
+ </div>
21
+ </div>
22
+ <div class="field-control">
23
+ <input type="text"
24
+ x-model="config.name_pattern" />
25
+ </div>
26
+ </div>
27
+
28
+ <div class="field">
29
+ <div class="field-label">
30
+ <div class="field-title">Max depth</div>
31
+ <div class="field-description">
32
+ Maximum directory depth to search.
33
+ </div>
34
+ </div>
35
+ <div class="field-control">
36
+ <input type="number" min="1" max="50"
37
+ x-model.number="config.max_depth" />
38
+ </div>
39
+ </div>
40
+
41
+ <div class="field">
42
+ <div class="field-label">
43
+ <div class="field-title">Max file tokens</div>
44
+ <div class="field-description">
45
+ Token limit per individual file. Longer files are cropped.
46
+ </div>
47
+ </div>
48
+ <div class="field-control">
49
+ <input type="number" min="100" max="20000"
50
+ x-model.number="config.max_file_tokens" />
51
+ </div>
52
+ </div>
53
+
54
+ <div class="field">
55
+ <div class="field-label">
56
+ <div class="field-title">Max file count</div>
57
+ <div class="field-description">
58
+ Maximum number of files to include.
59
+ </div>
60
+ </div>
61
+ <div class="field-control">
62
+ <input type="number" min="1" max="200"
63
+ x-model.number="config.max_file_count" />
64
+ </div>
65
+ </div>
66
+
67
+ <div class="field">
68
+ <div class="field-label">
69
+ <div class="field-title">Max total tokens</div>
70
+ <div class="field-description">
71
+ Total token budget for all included files combined.
72
+ </div>
73
+ </div>
74
+ <div class="field-control">
75
+ <input type="number" min="500" max="50000"
76
+ x-model.number="config.max_total_tokens" />
77
+ </div>
78
+ </div>
79
+
80
+ <div class="field">
81
+ <div class="field-label">
82
+ <div class="field-title">Gitignore patterns</div>
83
+ <div class="field-description">
84
+ Gitignore-style patterns to skip directories/files during search.
85
+ </div>
86
+ </div>
87
+ <div class="field-control">
88
+ <textarea rows="8"
89
+ x-model="config.gitignore"></textarea>
90
+ </div>
91
+ </div>
92
+ </div>
93
+ </template>
94
+ </div>
95
+</body>
96
+
97
+</html>