feat(systemprompt): $ZOT_HOME/SYSTEM.md as a persistent override
Resolution order for the system prompt is now: 1. --system-prompt <text> (per-run override; highest) 2. $ZOT_HOME/SYSTEM.md (persistent user override; new) 3. built-in defaultIdentity + defaultGuidelines When SYSTEM.md exists and --system-prompt is empty, its contents replace the entire identity + guidelines block (same semantics as --system-prompt). The tool list + skill manifest + appended sections + date/cwd footer are still added on top, so the file should contain just the identity / behavior text. readUserSystemPrompt swallows file errors on purpose: a missing or unreadable SYSTEM.md falls back to the built-in default rather than crashing the run. Cached on Resolved.systemCustom so MergeExtensionTools' system-prompt rebuild path also picks up the override. End-to-end verified live: with SYSTEM.md (pirate persona): "Arrr, I be Zot, a scallywag..." without: "I'm zot, a lightweight terminal..." Documented in README's $ZOT_HOME tree + the --system-prompt flag note.
- commit
- e425dbba5971fe3f51c0e051406e5370a6458365
- author
- patriceckhart · · 2026-04-19 13:47 UTC
- parent
- 84dbd86
- signature
- unsigned
- refs
- tag: v0.0.29
2 files · +34 -3
M README.md +4 -1
| @@ -102,9 +102,12 @@ $ZOT_HOME/ | |||
| 102 | 102 | ├── auth.json # api keys and oauth tokens (mode 0600) | |
| 103 | 103 | ├── sessions/ # jsonl transcripts, one dir per cwd | |
| 104 | 104 | ├── models-cache.json # live /v1/models discovery cache (6h ttl) | |
| 105 | + | ├── SYSTEM.md # optional: replaces the default system prompt | |
| 105 | 106 | └── logs/ # app log files | |
| 106 | 107 | ``` | |
| 107 | 108 | ||
| 109 | + | drop a `SYSTEM.md` in `$ZOT_HOME` to replace the built-in identity + guidelines for every run. `--system-prompt` still wins per-invocation. delete the file to revert to the default. | |
| 110 | + | ||
| 108 | 111 | ## usage | |
| 109 | 112 | ||
| 110 | 113 | ```bash | |
| @@ -126,7 +129,7 @@ zot --help | |||
| 126 | 129 | | `--model <id>` | pick the model (see `--list-models`) | | |
| 127 | 130 | | `--api-key <key>` | override api key | | |
| 128 | 131 | | `--base-url <url>` | override provider base url (tests / self-hosted) | | |
| 129 | - | | `--system-prompt <text>` | replace the default system prompt | | |
| 132 | + | | `--system-prompt <text>` | replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`) | | |
| 130 | 133 | | `--append-system-prompt <text>` | append text to the system prompt (repeatable) | | |
| 131 | 134 | | `--reasoning low\|medium\|high` | enable reasoning on supported models | | |
| 132 | 135 | | `-c`, `--continue` | resume the latest session for this cwd | | |
| @@ -102,9 +102,12 @@ $ZOT_HOME/ | |||||
| 102 | ├── auth.json # api keys and oauth tokens (mode 0600) | 102 | ├── auth.json # api keys and oauth tokens (mode 0600) | ||
| 103 | ├── sessions/ # jsonl transcripts, one dir per cwd | 103 | ├── sessions/ # jsonl transcripts, one dir per cwd | ||
| 104 | ├── models-cache.json # live /v1/models discovery cache (6h ttl) | 104 | ├── models-cache.json # live /v1/models discovery cache (6h ttl) | ||
| 105 | + | ├── SYSTEM.md # optional: replaces the default system prompt | |||
| 105 | └── logs/ # app log files | 106 | └── logs/ # app log files | ||
| 106 | ``` | 107 | ``` | ||
| 107 | 108 | ||||
| 109 | + | drop a `SYSTEM.md` in `$ZOT_HOME` to replace the built-in identity + guidelines for every run. `--system-prompt` still wins per-invocation. delete the file to revert to the default. | |||
| 110 | + | ||||
| 108 | ## usage | 111 | ## usage | ||
| 109 | 112 | ||||
| 110 | ```bash | 113 | ```bash | ||
| @@ -126,7 +129,7 @@ zot --help | |||||
| 126 | | `--model <id>` | pick the model (see `--list-models`) | | 129 | | `--model <id>` | pick the model (see `--list-models`) | | ||
| 127 | | `--api-key <key>` | override api key | | 130 | | `--api-key <key>` | override api key | | ||
| 128 | | `--base-url <url>` | override provider base url (tests / self-hosted) | | 131 | | `--base-url <url>` | override provider base url (tests / self-hosted) | | ||
| 129 | - | | `--system-prompt <text>` | replace the default system prompt | | 132 | + | | `--system-prompt <text>` | replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`) | |
| 130 | | `--append-system-prompt <text>` | append text to the system prompt (repeatable) | | 133 | | `--append-system-prompt <text>` | append text to the system prompt (repeatable) | | ||
| 131 | | `--reasoning low\|medium\|high` | enable reasoning on supported models | | 134 | | `--reasoning low\|medium\|high` | enable reasoning on supported models | | ||
| 132 | | `-c`, `--continue` | resume the latest session for this cwd | | 135 | | `-c`, `--continue` | resume the latest session for this cwd | | ||
M internal/agent/build.go +30 -2
| @@ -3,6 +3,8 @@ package agent | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strings" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/patriceckhart/zot/internal/agent/tools" | |
| 8 | 10 | "github.com/patriceckhart/zot/internal/core" | |
| @@ -199,10 +201,19 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 199 | 201 | append_ = append(append_, skillAddendum) | |
| 200 | 202 | } | |
| 201 | 203 | ||
| 204 | + | // Custom system prompt resolution order: | |
| 205 | + | // 1. --system-prompt flag (highest priority; ad-hoc per run) | |
| 206 | + | // 2. $ZOT_HOME/SYSTEM.md (persistent user override) | |
| 207 | + | // 3. built-in default (defaultIdentity + defaultGuidelines) | |
| 208 | + | custom := args.SystemPrompt | |
| 209 | + | if custom == "" { | |
| 210 | + | custom = readUserSystemPrompt(ZotHome()) | |
| 211 | + | } | |
| 212 | + | ||
| 202 | 213 | sys := BuildSystemPrompt(SystemPromptOpts{ | |
| 203 | 214 | CWD: args.CWD, | |
| 204 | 215 | Tools: summaries, | |
| 205 | - | Custom: args.SystemPrompt, | |
| 216 | + | Custom: custom, | |
| 206 | 217 | Append: append_, | |
| 207 | 218 | }) | |
| 208 | 219 | ||
| @@ -229,11 +240,28 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 229 | 240 | Sandbox: sandbox, | |
| 230 | 241 | SkillTool: skillTool, | |
| 231 | 242 | systemAppend: append_, | |
| 232 | - | systemCustom: args.SystemPrompt, | |
| 243 | + | systemCustom: custom, | |
| 233 | 244 | toolDescriptions: descMapFromSummaries(summaries), | |
| 234 | 245 | }, nil | |
| 235 | 246 | } | |
| 236 | 247 | ||
| 248 | + | // readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its | |
| 249 | + | // trimmed contents, or "" when the file is missing / unreadable / | |
| 250 | + | // empty. Errors are intentionally swallowed: the file is optional, | |
| 251 | + | // and any failure to read it should fall back to the built-in | |
| 252 | + | // default system prompt rather than crash the run. | |
| 253 | + | func readUserSystemPrompt(zotHome string) string { | |
| 254 | + | if zotHome == "" { | |
| 255 | + | return "" | |
| 256 | + | } | |
| 257 | + | path := filepath.Join(zotHome, "SYSTEM.md") | |
| 258 | + | raw, err := os.ReadFile(path) | |
| 259 | + | if err != nil { | |
| 260 | + | return "" | |
| 261 | + | } | |
| 262 | + | return strings.TrimSpace(string(raw)) | |
| 263 | + | } | |
| 264 | + | ||
| 237 | 265 | // descMapFromSummaries indexes the human-readable descriptions for | |
| 238 | 266 | // the renderToolsSection rebuild path. | |
| 239 | 267 | func descMapFromSummaries(summaries []ToolSummary) map[string]string { | |
| @@ -3,6 +3,8 @@ package agent | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "os" | 5 | "os" | ||
| 6 | + | "path/filepath" | |||
| 7 | + | "strings" | |||
| 6 | 8 | ||||
| 7 | "github.com/patriceckhart/zot/internal/agent/tools" | 9 | "github.com/patriceckhart/zot/internal/agent/tools" | ||
| 8 | "github.com/patriceckhart/zot/internal/core" | 10 | "github.com/patriceckhart/zot/internal/core" | ||
| @@ -199,10 +201,19 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 199 | append_ = append(append_, skillAddendum) | 201 | append_ = append(append_, skillAddendum) | ||
| 200 | } | 202 | } | ||
| 201 | 203 | ||||
| 204 | + | // Custom system prompt resolution order: | |||
| 205 | + | // 1. --system-prompt flag (highest priority; ad-hoc per run) | |||
| 206 | + | // 2. $ZOT_HOME/SYSTEM.md (persistent user override) | |||
| 207 | + | // 3. built-in default (defaultIdentity + defaultGuidelines) | |||
| 208 | + | custom := args.SystemPrompt | |||
| 209 | + | if custom == "" { | |||
| 210 | + | custom = readUserSystemPrompt(ZotHome()) | |||
| 211 | + | } | |||
| 212 | + | ||||
| 202 | sys := BuildSystemPrompt(SystemPromptOpts{ | 213 | sys := BuildSystemPrompt(SystemPromptOpts{ | ||
| 203 | CWD: args.CWD, | 214 | CWD: args.CWD, | ||
| 204 | Tools: summaries, | 215 | Tools: summaries, | ||
| 205 | - | Custom: args.SystemPrompt, | 216 | + | Custom: custom, |
| 206 | Append: append_, | 217 | Append: append_, | ||
| 207 | }) | 218 | }) | ||
| 208 | 219 | ||||
| @@ -229,11 +240,28 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 229 | Sandbox: sandbox, | 240 | Sandbox: sandbox, | ||
| 230 | SkillTool: skillTool, | 241 | SkillTool: skillTool, | ||
| 231 | systemAppend: append_, | 242 | systemAppend: append_, | ||
| 232 | - | systemCustom: args.SystemPrompt, | 243 | + | systemCustom: custom, |
| 233 | toolDescriptions: descMapFromSummaries(summaries), | 244 | toolDescriptions: descMapFromSummaries(summaries), | ||
| 234 | }, nil | 245 | }, nil | ||
| 235 | } | 246 | } | ||
| 236 | 247 | ||||
| 248 | + | // readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its | |||
| 249 | + | // trimmed contents, or "" when the file is missing / unreadable / | |||
| 250 | + | // empty. Errors are intentionally swallowed: the file is optional, | |||
| 251 | + | // and any failure to read it should fall back to the built-in | |||
| 252 | + | // default system prompt rather than crash the run. | |||
| 253 | + | func readUserSystemPrompt(zotHome string) string { | |||
| 254 | + | if zotHome == "" { | |||
| 255 | + | return "" | |||
| 256 | + | } | |||
| 257 | + | path := filepath.Join(zotHome, "SYSTEM.md") | |||
| 258 | + | raw, err := os.ReadFile(path) | |||
| 259 | + | if err != nil { | |||
| 260 | + | return "" | |||
| 261 | + | } | |||
| 262 | + | return strings.TrimSpace(string(raw)) | |||
| 263 | + | } | |||
| 264 | + | ||||
| 237 | // descMapFromSummaries indexes the human-readable descriptions for | 265 | // descMapFromSummaries indexes the human-readable descriptions for | ||
| 238 | // the renderToolsSection rebuild path. | 266 | // the renderToolsSection rebuild path. | ||
| 239 | func descMapFromSummaries(summaries []ToolSummary) map[string]string { | 267 | func descMapFromSummaries(summaries []ToolSummary) map[string]string { | ||