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>