From 4e8ff2745844ec0f5dd9ec095944aeb8ca1f9def Mon Sep 17 00:00:00 2001 From: pdfinn Date: Fri, 23 Jan 2026 10:06:47 +0700 Subject: [PATCH] feat: Add system prompt file for persistent persona configuration - Add system file (read/write) to set system prompt - System prompt persists across conversation resets - Add SystemPrompt() and SetSystemPrompt() to Backend interface - Update both API and CLI clients to support dedicated system prompt - Update documentation and examples Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 14 +++++++--- README.md | 12 +++++++-- internal/llm/backend.go | 8 ++++-- internal/llm/cli_client.go | 38 ++++++++++++++++++++------- internal/llm/client.go | 54 +++++++++++++++++++++++++++++--------- internal/llmfs/example.go | 11 +++++--- internal/llmfs/root.go | 1 + internal/llmfs/system.go | 52 ++++++++++++++++++++++++++++++++++++ 8 files changed, 157 insertions(+), 33 deletions(-) create mode 100644 internal/llmfs/system.go diff --git a/CLAUDE.md b/CLAUDE.md index 7f29831093d762d817c167c03250dae4faa043b2..21f70dd5a7be61d67c391484b6d1ac864835d712 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,7 @@ llm9p/ │ ├── root.go # Root directory construction │ ├── ask.go # Ask file (shim pattern) │ ├── state.go # Model, temperature files +│ ├── system.go # System prompt file │ ├── tokens.go # Read-only token counter │ ├── new.go # Conversation reset trigger │ ├── context.go # Conversation history @@ -215,11 +216,15 @@ This logs all 9P messages sent and received. 9p -a localhost:5640 write llm/ask "What number did I just mention?" 9p -a localhost:5640 read llm/ask # Returns "42" -# Add system message (e.g., persona) -9p -a localhost:5640 write llm/context "Respond like a pirate" +# Set system prompt (e.g., persona) +9p -a localhost:5640 write llm/system "Respond like a pirate" 9p -a localhost:5640 write llm/ask "Hello" 9p -a localhost:5640 read llm/ask # Pirate-style response +# System prompt persists across resets +9p -a localhost:5640 write llm/new "reset" +9p -a localhost:5640 read llm/system # Still "Respond like a pirate" + # Reset conversation 9p -a localhost:5640 write llm/new "reset" ``` @@ -307,8 +312,9 @@ The following scenarios have been tested and verified working: - [x] `write llm/temperature "0.5"` - Updates temperature setting - [x] `write llm/ask "What is 2+2?"` followed by `read llm/ask` - Returns "4" - [x] Multi-turn conversation maintains context -- [x] `write llm/context "Respond like a pirate"` - System message works -- [x] `write llm/new "reset"` - Clears conversation history +- [x] `write llm/system "Respond like a pirate"` - System prompt works +- [x] System prompt persists across conversation resets +- [x] `write llm/new "reset"` - Clears conversation history (keeps system prompt) ### Infernode (Inferno OS) - [x] `mount -A tcp!127.0.0.1!5640 /n/llm` - Mounts successfully diff --git a/README.md b/README.md index d7f5d61a5e604dd82b9b6474361df200d3fc2299..a41859c0b0ad61905478524e013afc3140999805 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,12 @@ echo "claude-3-haiku-20240307" > /mnt/llm/model # Adjust temperature echo "0.5" > /mnt/llm/temperature +# Set a system prompt (persists across conversation resets) +echo "You are a helpful coding assistant." > /mnt/llm/system + +# View current system prompt +cat /mnt/llm/system + # View conversation history cat /mnt/llm/context @@ -161,6 +167,7 @@ cat /mnt/llm/_example ├── ask # Write prompt, read response (same file) ├── model # Read/write: current model name ├── temperature # Read/write: temperature float (0.0-2.0) +├── system # Read/write: system prompt (persists across resets) ├── tokens # Read-only: last response token count ├── new # Write anything to start fresh conversation ├── context # Read: conversation history; Write: add system message @@ -177,9 +184,10 @@ cat /mnt/llm/_example | `ask` | Returns last LLM response | Sends prompt to LLM (sync), stores response | | `model` | Returns current model name | Sets model for subsequent requests | | `temperature` | Returns current temperature | Sets temperature (0.0-2.0) | +| `system` | Returns current system prompt | Sets system prompt (persists across resets) | | `tokens` | Returns last response token count | Permission denied | -| `new` | Permission denied | Any write resets conversation state | -| `context` | Returns JSON conversation history | Appends system message to context | +| `new` | Permission denied | Any write resets conversation (keeps system prompt) | +| `context` | Returns JSON conversation history | Appends system message to history | | `_example` | Returns usage examples | Permission denied | | `stream/ask` | Permission denied | Starts a streaming request | | `stream/chunk` | Blocks until next chunk, returns it | Permission denied | diff --git a/internal/llm/backend.go b/internal/llm/backend.go index 3df5776591e3e7957b8c4ab90256b901ce9fd6eb..b324f67df2897ab53c906abb124ec4ea9207a9c6 100644 --- a/internal/llm/backend.go +++ b/internal/llm/backend.go @@ -14,15 +14,19 @@ type Backend interface { Temperature() float64 // SetTemperature sets the temperature (0.0-2.0) SetTemperature(temp float64) error + // SystemPrompt returns the current system prompt + SystemPrompt() string + // SetSystemPrompt sets the system prompt + SetSystemPrompt(prompt string) // LastTokens returns token count from last response LastTokens() int // Messages returns conversation history Messages() []Message // MessagesJSON returns conversation history as JSON MessagesJSON() ([]byte, error) - // AddSystemMessage adds a system message + // AddSystemMessage adds a system message to conversation history AddSystemMessage(content string) - // Reset clears conversation history + // Reset clears conversation history (but preserves system prompt) Reset() // Ask sends a prompt and returns the response (blocking) Ask(ctx context.Context, prompt string) (string, error) diff --git a/internal/llm/cli_client.go b/internal/llm/cli_client.go index 7e312bff21e7e4d209a9d3259ee50f684754e240..1d4b499b44bc208aee5fff9d3cd6ab4a3303c81b 100644 --- a/internal/llm/cli_client.go +++ b/internal/llm/cli_client.go @@ -15,14 +15,15 @@ import ( // CLIClient uses the Claude Code CLI for LLM requests. // This allows using a Claude Max subscription instead of API tokens. type CLIClient struct { - mu sync.RWMutex - model string - temperature float64 - messages []Message - lastTokens int - streaming bool - streamChan chan string - streamDone chan struct{} + mu sync.RWMutex + model string + temperature float64 + systemPrompt string + messages []Message + lastTokens int + streaming bool + streamChan chan string + streamDone chan struct{} } // cliResponse represents the JSON response from claude CLI @@ -85,6 +86,20 @@ func (c *CLIClient) SetTemperature(temp float64) error { return nil } +// SystemPrompt returns the current system prompt +func (c *CLIClient) SystemPrompt() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.systemPrompt +} + +// SetSystemPrompt sets the system prompt for subsequent requests +func (c *CLIClient) SetSystemPrompt(prompt string) { + c.mu.Lock() + defer c.mu.Unlock() + c.systemPrompt = prompt +} + // LastTokens returns the token count from the last response // Note: CLI doesn't provide token counts, so this is always 0 func (c *CLIClient) LastTokens() int { @@ -138,9 +153,14 @@ func (c *CLIClient) buildPrompt() string { return strings.Join(parts, "\n\n") } -// getSystemPrompt extracts system messages as a single string +// getSystemPrompt builds the full system prompt from dedicated prompt and history func (c *CLIClient) getSystemPrompt() string { var systems []string + // Add dedicated system prompt first + if c.systemPrompt != "" { + systems = append(systems, c.systemPrompt) + } + // Also include system messages from conversation history for _, msg := range c.messages { if msg.Role == "system" { systems = append(systems, msg.Content) diff --git a/internal/llm/client.go b/internal/llm/client.go index 3c8342fd7c88c48891f4a22f6aca1a249a1af222..7587eb6e1ce7c2dbc17a0120f2d8448d014f9545 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -19,15 +19,16 @@ type Message struct { // Client wraps the Anthropic API client with conversation state type Client struct { - client anthropic.Client - mu sync.RWMutex - model string - temperature float64 - messages []Message - lastTokens int - streaming bool - streamChan chan string - streamDone chan struct{} + client anthropic.Client + mu sync.RWMutex + model string + temperature float64 + systemPrompt string + messages []Message + lastTokens int + streaming bool + streamChan chan string + streamDone chan struct{} } // NewClient creates a new LLM client @@ -73,6 +74,20 @@ func (c *Client) SetTemperature(temp float64) error { return nil } +// SystemPrompt returns the current system prompt +func (c *Client) SystemPrompt() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.systemPrompt +} + +// SetSystemPrompt sets the system prompt for subsequent requests +func (c *Client) SetSystemPrompt(prompt string) { + c.mu.Lock() + defer c.mu.Unlock() + c.systemPrompt = prompt +} + // LastTokens returns the token count from the last response func (c *Client) LastTokens() int { c.mu.RLock() @@ -118,14 +133,21 @@ func (c *Client) Ask(ctx context.Context, prompt string) (string, error) { // Add user message to history c.messages = append(c.messages, Message{Role: "user", Content: prompt}) - // Build the API messages + // Build the API messages from conversation history apiMessages := make([]anthropic.MessageParam, 0, len(c.messages)) var systemBlocks []anthropic.TextBlockParam + // Add dedicated system prompt first + if c.systemPrompt != "" { + systemBlocks = append(systemBlocks, anthropic.TextBlockParam{ + Text: c.systemPrompt, + }) + } + for _, msg := range c.messages { switch msg.Role { case "system": - // Collect system messages + // Also include system messages from conversation history systemBlocks = append(systemBlocks, anthropic.TextBlockParam{ Text: msg.Content, }) @@ -197,13 +219,21 @@ func (c *Client) StartStream(ctx context.Context, prompt string) error { // Add user message to history c.messages = append(c.messages, Message{Role: "user", Content: prompt}) - // Build the API messages + // Build the API messages from conversation history apiMessages := make([]anthropic.MessageParam, 0, len(c.messages)) var systemBlocks []anthropic.TextBlockParam + // Add dedicated system prompt first + if c.systemPrompt != "" { + systemBlocks = append(systemBlocks, anthropic.TextBlockParam{ + Text: c.systemPrompt, + }) + } + for _, msg := range c.messages { switch msg.Role { case "system": + // Also include system messages from conversation history systemBlocks = append(systemBlocks, anthropic.TextBlockParam{ Text: msg.Content, }) diff --git a/internal/llmfs/example.go b/internal/llmfs/example.go index 61ff95d23f242028f8eb677de1db08771a0667e7..2b63bef037c92103912cab424ecbdab752f20fae 100644 --- a/internal/llmfs/example.go +++ b/internal/llmfs/example.go @@ -16,11 +16,13 @@ Configuration: echo "claude-3-haiku-20240307" > model # Change model cat temperature # View current temperature (0.0-2.0) echo "0.5" > temperature # Set temperature + cat system # View current system prompt + echo "You are a helpful coding assistant." > system # Set system prompt Conversation Management: cat context # View conversation history (JSON) - echo "You are a helpful assistant." > context # Add system message - echo "" > new # Reset conversation + echo "Additional context..." > context # Add system message to history + echo "" > new # Reset conversation (keeps system prompt) Token Usage: cat tokens # View tokens from last response @@ -51,9 +53,10 @@ Files: ask Read/write: prompt goes in, response comes out (sync) model Read/write: current model name temperature Read/write: sampling temperature (0.0-2.0) + system Read/write: system prompt (persists across resets) tokens Read-only: token count from last response - new Write-only: any write resets conversation - context Read: JSON history; Write: add system message + new Write-only: any write resets conversation (keeps system prompt) + context Read: JSON history; Write: add system message to history _example Read-only: this help text stream/ask Write-only: starts a streaming request stream/chunk Read-only: returns next chunk (blocks), EOF when done diff --git a/internal/llmfs/root.go b/internal/llmfs/root.go index 26f92f023ea5ba647b19f4801e220bc52fbe3d78..fbbf4f24022992aaa831151e4b7ab23cd8b4fcbb 100644 --- a/internal/llmfs/root.go +++ b/internal/llmfs/root.go @@ -14,6 +14,7 @@ func NewRoot(client llm.Backend) protocol.Dir { root.AddChild(NewAskFile(client)) root.AddChild(NewModelFile(client)) root.AddChild(NewTemperatureFile(client)) + root.AddChild(NewSystemFile(client)) root.AddChild(NewTokensFile(client)) root.AddChild(NewNewFile(client)) root.AddChild(NewContextFile(client)) diff --git a/internal/llmfs/system.go b/internal/llmfs/system.go new file mode 100644 index 0000000000000000000000000000000000000000..b262a9b271de27058cf21012b2b12aab8bcb5b32 --- /dev/null +++ b/internal/llmfs/system.go @@ -0,0 +1,52 @@ +package llmfs + +import ( + "io" + "strings" + + "github.com/NERVsystems/llm9p/internal/llm" + "github.com/NERVsystems/llm9p/internal/protocol" +) + +// SystemFile exposes the system prompt (read/write) +type SystemFile struct { + *protocol.BaseFile + client llm.Backend +} + +// NewSystemFile creates the system file +func NewSystemFile(client llm.Backend) *SystemFile { + return &SystemFile{ + BaseFile: protocol.NewBaseFile("system", 0666), + client: client, + } +} + +func (f *SystemFile) Read(p []byte, offset int64) (int, error) { + content := f.client.SystemPrompt() + if content != "" { + content += "\n" + } + if offset >= int64(len(content)) { + return 0, io.EOF + } + n := copy(p, content[offset:]) + return n, nil +} + +func (f *SystemFile) Write(p []byte, offset int64) (int, error) { + prompt := strings.TrimSpace(string(p)) + f.client.SetSystemPrompt(prompt) + return len(p), nil +} + +func (f *SystemFile) Stat() protocol.Stat { + s := f.BaseFile.Stat() + content := f.client.SystemPrompt() + if content != "" { + s.Length = uint64(len(content) + 1) // +1 for newline + } else { + s.Length = 0 + } + return s +}