M internal/llm/backend.go => internal/llm/backend.go +3 -0
@@ 49,6 49,9 @@ type Backend interface {
// AskWithHistory sends a prompt with explicit message history (for per-fid isolation)
// Returns response text and token count
AskWithHistory(ctx context.Context, history []Message, prompt string) (string, int, error)
+ // AskWithRequest sends a prompt with all settings from the request (CSP - no client state)
+ // This is the primary method for the clone-based session architecture.
+ AskWithRequest(ctx context.Context, req AskRequest) (string, int, error)
// StartStream begins streaming a response
StartStream(ctx context.Context, prompt string) error
// ReadStreamChunk reads the next streaming chunk
M internal/llm/cli_client.go => internal/llm/cli_client.go +95 -1
@@ 646,7 646,8 @@ func (c *CLIClient) AskWithHistory(ctx context.Context, history []Message, promp
// Prepend prefill to response to keep model in character
// Note: CLI doesn't support true prefill (partial assistant message),
// so we prepend it to the response for consistent behavior with API client
- if prefill != "" {
+ // Only add if response doesn't already start with it (model may echo from history)
+ if prefill != "" && !strings.HasPrefix(responseText, prefill) {
responseText = prefill + responseText
}
@@ 655,3 656,96 @@ func (c *CLIClient) AskWithHistory(ctx context.Context, history []Message, promp
return responseText, tokens, nil
}
+
+// AskWithRequest sends a prompt with all settings from the request (CSP - no client state).
+// This is the primary method for the clone-based session architecture.
+// All settings come from the request parameter, making this a stateless API call.
+func (c *CLIClient) AskWithRequest(ctx context.Context, req AskRequest) (string, int, error) {
+ // Build prompt from provided history
+ var parts []string
+ var systemParts []string
+
+ // Add system prompt from request
+ if req.SystemPrompt != "" {
+ systemParts = append(systemParts, req.SystemPrompt)
+ }
+
+ for _, msg := range req.Messages {
+ switch msg.Role {
+ case "system":
+ systemParts = append(systemParts, msg.Content)
+ case "user":
+ parts = append(parts, fmt.Sprintf("Human: %s", msg.Content))
+ case "assistant":
+ parts = append(parts, fmt.Sprintf("Assistant: %s", msg.Content))
+ }
+ }
+
+ // Add the new user prompt
+ parts = append(parts, fmt.Sprintf("Human: %s", req.Prompt))
+
+ fullPrompt := strings.Join(parts, "\n\n")
+ systemPrompt := strings.Join(systemParts, "\n\n")
+
+ // Use model from request, normalize to CLI alias
+ model := normalizeModel(req.Model)
+ if req.Model == "" {
+ c.mu.RLock()
+ model = c.model
+ c.mu.RUnlock()
+ }
+
+ // Use thinking tokens from request
+ thinkingTokens := req.ThinkingTokens
+
+ // Build claude CLI command
+ args := []string{
+ "--print",
+ "--output-format", "json",
+ "--model", model,
+ "--allowedTools", "",
+ "--dangerously-skip-permissions",
+ }
+
+ if systemPrompt != "" {
+ args = append(args, "--system-prompt", systemPrompt)
+ }
+
+ args = append(args, "-") // Read from stdin
+
+ cmd := exec.CommandContext(ctx, "claude", args...)
+ cmd.Stdin = bytes.NewBufferString(fullPrompt)
+
+ // Set thinking token budget via environment variable
+ cmd.Env = append(cmd.Environ(), func() string {
+ if thinkingTokens < 0 {
+ return "MAX_THINKING_TOKENS=31999"
+ }
+ return fmt.Sprintf("MAX_THINKING_TOKENS=%d", thinkingTokens)
+ }())
+
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ return "", 0, fmt.Errorf("claude CLI error: %w (stderr: %s)", err, stderr.String())
+ }
+
+ // Parse JSON response
+ responseText, err := parseJSONResponse(stdout.String())
+ if err != nil {
+ return "", 0, fmt.Errorf("failed to parse CLI response: %w", err)
+ }
+
+ // Prepend prefill to response to keep model in character
+ // Only add if response doesn't already start with it (model may echo from history)
+ if req.Prefill != "" && !strings.HasPrefix(responseText, req.Prefill) {
+ responseText = req.Prefill + responseText
+ }
+
+ // Estimate tokens: prompt + response (chars / 4)
+ tokens := estimateTokens(fullPrompt) + estimateTokens(responseText)
+
+ return responseText, tokens, nil
+}
M internal/llm/client.go => internal/llm/client.go +101 -0
@@ 612,3 612,104 @@ func (c *Client) AskWithHistory(ctx context.Context, history []Message, prompt s
return responseText, tokens, nil
}
+
+// AskWithRequest sends a prompt with all settings from the request (CSP - no client state).
+// This is the primary method for the clone-based session architecture.
+// All settings come from the request parameter, making this a stateless API call.
+func (c *Client) AskWithRequest(ctx context.Context, req AskRequest) (string, int, error) {
+ // Build API messages from provided history plus the new prompt
+ apiMessages := make([]anthropic.MessageParam, 0, len(req.Messages)+2)
+ var systemBlocks []anthropic.TextBlockParam
+
+ // Add system prompt from request
+ if req.SystemPrompt != "" {
+ systemBlocks = append(systemBlocks, anthropic.TextBlockParam{
+ Text: req.SystemPrompt,
+ })
+ }
+
+ for _, msg := range req.Messages {
+ switch msg.Role {
+ case "system":
+ systemBlocks = append(systemBlocks, anthropic.TextBlockParam{
+ Text: msg.Content,
+ })
+ case "user":
+ apiMessages = append(apiMessages, anthropic.NewUserMessage(
+ anthropic.NewTextBlock(msg.Content),
+ ))
+ case "assistant":
+ apiMessages = append(apiMessages, anthropic.NewAssistantMessage(
+ anthropic.NewTextBlock(msg.Content),
+ ))
+ }
+ }
+
+ // Add the new user prompt
+ apiMessages = append(apiMessages, anthropic.NewUserMessage(
+ anthropic.NewTextBlock(req.Prompt),
+ ))
+
+ // Add prefill as partial assistant message to keep model in character
+ if req.Prefill != "" {
+ apiMessages = append(apiMessages, anthropic.NewAssistantMessage(
+ anthropic.NewTextBlock(req.Prefill),
+ ))
+ }
+
+ // Use model from request, or fall back to client default
+ model := req.Model
+ if model == "" {
+ c.mu.RLock()
+ model = c.model
+ c.mu.RUnlock()
+ }
+
+ // Use temperature from request
+ temp := req.Temperature
+
+ // Build request params
+ params := anthropic.MessageNewParams{
+ Model: anthropic.Model(model),
+ MaxTokens: 4096,
+ Messages: apiMessages,
+ Temperature: anthropic.Float(temp),
+ }
+
+ // Add system prompt if present
+ if len(systemBlocks) > 0 {
+ params.System = systemBlocks
+ }
+
+ // Make the API call with timing
+ startTime := time.Now()
+ response, err := c.client.Messages.New(ctx, params)
+ latencyMs := time.Since(startTime).Milliseconds()
+
+ if err != nil {
+ return "", 0, fmt.Errorf("API error: %w", err)
+ }
+
+ // Extract response text
+ var responseText string
+ for _, block := range response.Content {
+ if block.Type == "text" {
+ responseText += block.Text
+ }
+ }
+
+ // Prepend prefill to response (it was used as partial assistant message)
+ // Only if response doesn't already start with it (model may echo from history)
+ if req.Prefill != "" && !strings.HasPrefix(responseText, req.Prefill) {
+ responseText = req.Prefill + responseText
+ }
+
+ tokens := int(response.Usage.InputTokens + response.Usage.OutputTokens)
+
+ // Record metrics
+ inputToks := int(response.Usage.InputTokens)
+ outputToks := int(response.Usage.OutputTokens)
+ RecordMetrics(inputToks, outputToks, latencyMs)
+
+ return responseText, tokens, nil
+}
M internal/llm/session.go => internal/llm/session.go +235 -70
@@ 7,22 7,56 @@ import (
"sync"
)
-// Session holds per-fid conversation state.
-// Each fid that opens the ask file gets its own session with isolated history.
+// SessionDefaults are copied to new sessions at creation time.
+type SessionDefaults struct {
+ Model string
+ Temperature float64
+ SystemPrompt string
+ ThinkingTokens int
+ Prefill string
+}
+
+// DefaultSessionDefaults returns sensible defaults for new sessions.
+func DefaultSessionDefaults() SessionDefaults {
+ return SessionDefaults{
+ Model: "claude-sonnet-4-20250514",
+ Temperature: 0.7,
+ SystemPrompt: "",
+ ThinkingTokens: 0,
+ Prefill: "",
+ }
+}
+
+// Session holds ALL state for one session - fully independent (CSP).
+// Each session is a complete, isolated unit with no shared mutable state.
type Session struct {
- ID uint32
+ ID int
messages []Message
lastResponse string
lastTokens int
totalTokens int
- mu sync.RWMutex
+
+ // Per-session settings (no globals - CSP compliant)
+ model string
+ temperature float64
+ systemPrompt string
+ thinkingTokens int
+ prefill string
+
+ mu sync.RWMutex
+ closed bool
}
-// NewSession creates a new session for the given fid.
-func NewSession(fid uint32) *Session {
+// NewSession creates a new session with the given ID and defaults.
+func NewSession(id int, defaults SessionDefaults) *Session {
return &Session{
- ID: fid,
- messages: make([]Message, 0),
+ ID: id,
+ messages: make([]Message, 0),
+ model: defaults.Model,
+ temperature: defaults.Temperature,
+ systemPrompt: defaults.SystemPrompt,
+ thinkingTokens: defaults.ThinkingTokens,
+ prefill: defaults.Prefill,
}
}
@@ 49,13 83,6 @@ func (s *Session) AddMessage(role, content string) {
s.messages = append(s.messages, Message{Role: role, Content: content})
}
-// AddSystemMessage adds a system message to the session's history.
-func (s *Session) AddSystemMessage(content string) {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.messages = append([]Message{{Role: "system", Content: content}}, s.messages...)
-}
-
// SetLastResponse sets the last response for this session.
func (s *Session) SetLastResponse(response string) {
s.mu.Lock()
@@ 70,13 97,6 @@ func (s *Session) LastResponse() string {
return s.lastResponse
}
-// LastTokens returns the token count from the last response.
-func (s *Session) LastTokens() int {
- s.mu.RLock()
- defer s.mu.RUnlock()
- return s.lastTokens
-}
-
// TotalTokens returns cumulative token count for this session.
func (s *Session) TotalTokens() int {
s.mu.RLock()
@@ 84,14 104,6 @@ func (s *Session) TotalTokens() int {
return s.totalTokens
}
-// SetTokens updates the token counts for this session.
-func (s *Session) SetTokens(last, total int) {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.lastTokens = last
- s.totalTokens = total
-}
-
// AddTokens adds to the token counts for this session.
func (s *Session) AddTokens(tokens int) {
s.mu.Lock()
@@ 100,7 112,7 @@ func (s *Session) AddTokens(tokens int) {
s.totalTokens += tokens
}
-// Reset clears the session's conversation history.
+// Reset clears the session's conversation history but keeps settings.
func (s *Session) Reset() {
s.mu.Lock()
defer s.mu.Unlock()
@@ 110,74 122,199 @@ func (s *Session) Reset() {
s.totalTokens = 0
}
-// SessionManager maps fids to sessions and delegates to a shared backend.
+// Model returns the session's model setting.
+func (s *Session) Model() string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.model
+}
+
+// SetModel sets the session's model.
+func (s *Session) SetModel(model string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.model = model
+}
+
+// Temperature returns the session's temperature setting.
+func (s *Session) Temperature() float64 {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.temperature
+}
+
+// SetTemperature sets the session's temperature.
+func (s *Session) SetTemperature(temp float64) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.temperature = temp
+}
+
+// SystemPrompt returns the session's system prompt.
+func (s *Session) SystemPrompt() string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.systemPrompt
+}
+
+// SetSystemPrompt sets the session's system prompt.
+func (s *Session) SetSystemPrompt(prompt string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.systemPrompt = prompt
+}
+
+// ThinkingTokens returns the session's thinking token budget.
+func (s *Session) ThinkingTokens() int {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.thinkingTokens
+}
+
+// SetThinkingTokens sets the session's thinking token budget.
+func (s *Session) SetThinkingTokens(tokens int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.thinkingTokens = tokens
+}
+
+// Prefill returns the session's prefill string.
+func (s *Session) Prefill() string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.prefill
+}
+
+// SetPrefill sets the session's prefill string.
+func (s *Session) SetPrefill(prefill string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.prefill = prefill
+}
+
+// IsClosed returns whether the session has been closed.
+func (s *Session) IsClosed() bool {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.closed
+}
+
+// SessionManager manages sessions and provides API access.
+// The APIClient is stateless - all conversation state is in sessions.
type SessionManager struct {
- sessions map[uint32]*Session
- backend Backend // shared backend for API calls and global settings
- mu sync.RWMutex
+ sessions map[int]*Session
+ nextID int
+ apiClient Backend // Stateless API caller
+ defaults SessionDefaults // Defaults for new sessions
+ mu sync.RWMutex
}
-// NewSessionManager creates a new session manager with the given backend.
-func NewSessionManager(backend Backend) *SessionManager {
+// NewSessionManager creates a new session manager.
+func NewSessionManager(apiClient Backend) *SessionManager {
return &SessionManager{
- sessions: make(map[uint32]*Session),
- backend: backend,
+ sessions: make(map[int]*Session),
+ nextID: 0,
+ apiClient: apiClient,
+ defaults: DefaultSessionDefaults(),
}
}
-// Backend returns the underlying shared backend.
-func (sm *SessionManager) Backend() Backend {
- return sm.backend
+// SetDefaults sets the defaults for new sessions.
+func (sm *SessionManager) SetDefaults(defaults SessionDefaults) {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+ sm.defaults = defaults
}
-// GetOrCreate returns the session for the given fid, creating one if necessary.
-func (sm *SessionManager) GetOrCreate(fid uint32) *Session {
+// Create creates a new session and returns its ID.
+func (sm *SessionManager) Create() int {
sm.mu.Lock()
defer sm.mu.Unlock()
- if s, ok := sm.sessions[fid]; ok {
- return s
- }
- s := NewSession(fid)
- sm.sessions[fid] = s
- return s
+
+ id := sm.nextID
+ sm.nextID++
+
+ sm.sessions[id] = NewSession(id, sm.defaults)
+ return id
}
-// Get returns the session for the given fid, or nil if it doesn't exist.
-func (sm *SessionManager) Get(fid uint32) *Session {
+// Get returns the session with the given ID, or nil if not found.
+func (sm *SessionManager) Get(id int) *Session {
sm.mu.RLock()
defer sm.mu.RUnlock()
- return sm.sessions[fid]
+ return sm.sessions[id]
}
-// Remove removes the session for the given fid.
-func (sm *SessionManager) Remove(fid uint32) {
+// Close closes and removes the session with the given ID.
+func (sm *SessionManager) Close(id int) error {
sm.mu.Lock()
defer sm.mu.Unlock()
- delete(sm.sessions, fid)
+
+ session, ok := sm.sessions[id]
+ if !ok {
+ return nil // Already closed
+ }
+
+ session.mu.Lock()
+ session.closed = true
+ session.mu.Unlock()
+
+ delete(sm.sessions, id)
+ return nil
}
-// Reset clears the session for the given fid (but keeps the session).
-func (sm *SessionManager) Reset(fid uint32) {
- session := sm.GetOrCreate(fid)
+// Reset clears the conversation history for the given session.
+func (sm *SessionManager) Reset(id int) error {
+ session := sm.Get(id)
+ if session == nil {
+ return nil
+ }
session.Reset()
+ return nil
}
-// Ask sends a prompt using the session's conversation history.
+// Ask sends a prompt using the session's conversation history and settings.
// The response is stored in the session and returned.
-func (sm *SessionManager) Ask(ctx context.Context, fid uint32, prompt string) (string, error) {
- session := sm.GetOrCreate(fid)
+func (sm *SessionManager) Ask(ctx context.Context, id int, prompt string) (string, error) {
+ session := sm.Get(id)
+ if session == nil {
+ return "", ErrSessionNotFound
+ }
+
+ if session.IsClosed() {
+ return "", ErrSessionClosed
+ }
+
+ // Get session settings
+ session.mu.RLock()
+ history := make([]Message, len(session.messages))
+ copy(history, session.messages)
+ model := session.model
+ temperature := session.temperature
+ systemPrompt := session.systemPrompt
+ thinkingTokens := session.thinkingTokens
+ prefill := session.prefill
+ session.mu.RUnlock()
- // Get current history before adding new message
- history := session.Messages()
+ // Build request with session's settings
+ req := AskRequest{
+ Messages: history,
+ Prompt: prompt,
+ Model: model,
+ Temperature: temperature,
+ SystemPrompt: systemPrompt,
+ ThinkingTokens: thinkingTokens,
+ Prefill: prefill,
+ }
- // Use backend's AskWithHistory - it doesn't modify backend state
- response, tokens, err := sm.backend.AskWithHistory(ctx, history, prompt)
+ // Make API call (stateless)
+ response, tokens, err := sm.apiClient.AskWithRequest(ctx, req)
if err != nil {
session.SetLastResponse("Error: " + err.Error())
return "", err
}
- // Add user message and assistant response to session history
+ // Update session state
session.AddMessage("user", prompt)
session.AddMessage("assistant", response)
session.AddTokens(tokens)
@@ 186,7 323,35 @@ func (sm *SessionManager) Ask(ctx context.Context, fid uint32, prompt string) (s
return response, nil
}
-// ContextLimit returns the model's context window limit from the backend.
-func (sm *SessionManager) ContextLimit() int {
- return sm.backend.ContextLimit()
+// ListSessions returns the IDs of all active sessions.
+func (sm *SessionManager) ListSessions() []int {
+ sm.mu.RLock()
+ defer sm.mu.RUnlock()
+
+ ids := make([]int, 0, len(sm.sessions))
+ for id := range sm.sessions {
+ ids = append(ids, id)
+ }
+ return ids
}
+
+// AskRequest contains all parameters for an API call.
+type AskRequest struct {
+ Messages []Message
+ Prompt string
+ Model string
+ Temperature float64
+ SystemPrompt string
+ ThinkingTokens int
+ Prefill string
+}
+
+// Errors
+type SessionError string
+
+func (e SessionError) Error() string { return string(e) }
+
+const (
+ ErrSessionNotFound SessionError = "session not found"
+ ErrSessionClosed SessionError = "session closed"
+)
D internal/llmfs/ask_test.go => internal/llmfs/ask_test.go +0 -227
@@ 1,227 0,0 @@
-package llmfs
-
-import (
- "io"
- "testing"
-
- "github.com/NERVsystems/llm9p/internal/llm"
-)
-
-func TestAskFile_Read_Empty(t *testing.T) {
- mock := NewMockBackend()
- sm := llm.NewSessionManager(mock)
- ask := NewAskFile(sm)
-
- // Initial read with fid should be empty (EOF)
- buf := make([]byte, 100)
- n, err := ask.ReadFid(1, buf, 0)
- if err != io.EOF {
- t.Errorf("ReadFid() error = %v, want io.EOF", err)
- }
- if n != 0 {
- t.Errorf("ReadFid() n = %d, want 0", n)
- }
-}
-
-func TestAskFile_WriteRead(t *testing.T) {
- mock := NewMockBackend()
- mock.askResponse = "Hello, I'm Claude!"
-
- sm := llm.NewSessionManager(mock)
- ask := NewAskFile(sm)
-
- fid := uint32(1)
-
- // Write a prompt
- prompt := "Hello!"
- n, err := ask.WriteFid(fid, []byte(prompt), 0)
- if err != nil {
- t.Fatalf("WriteFid() error: %v", err)
- }
- if n != len(prompt) {
- t.Errorf("WriteFid() n = %d, want %d", n, len(prompt))
- }
-
- // Read response
- buf := make([]byte, 100)
- readN, err := ask.ReadFid(fid, buf, 0)
- if err != nil {
- t.Fatalf("ReadFid() error: %v", err)
- }
-
- response := string(buf[:readN])
- expected := "Hello, I'm Claude!\n"
- if response != expected {
- t.Errorf("ReadFid() = %q, want %q", response, expected)
- }
-}
-
-func TestAskFile_Write_EmptyNoOp(t *testing.T) {
- mock := NewMockBackend()
- sm := llm.NewSessionManager(mock)
- ask := NewAskFile(sm)
-
- fid := uint32(1)
-
- // Empty write should be no-op
- n, err := ask.WriteFid(fid, []byte(""), 0)
- if err != nil {
- t.Fatalf("WriteFid() error: %v", err)
- }
- if n != 0 {
- t.Errorf("WriteFid('') n = %d, want 0", n)
- }
-
- // Whitespace-only also no-op
- n, err = ask.WriteFid(fid, []byte(" \n\t "), 0)
- if err != nil {
- t.Fatalf("WriteFid() error: %v", err)
- }
-}
-
-func TestAskFile_Write_Error(t *testing.T) {
- mock := NewMockBackend()
- mock.askError = io.ErrUnexpectedEOF
-
- sm := llm.NewSessionManager(mock)
- ask := NewAskFile(sm)
-
- fid := uint32(1)
-
- // Write should succeed (error is stored for read)
- n, err := ask.WriteFid(fid, []byte("test"), 0)
- if err != nil {
- t.Fatalf("WriteFid() error: %v", err)
- }
- if n != 4 {
- t.Errorf("WriteFid() n = %d, want 4", n)
- }
-
- // Read should return the error
- buf := make([]byte, 100)
- readN, _ := ask.ReadFid(fid, buf, 0)
- response := string(buf[:readN])
-
- if len(response) < 6 || response[:6] != "Error:" {
- t.Errorf("ReadFid() = %q, should start with 'Error:'", response)
- }
-}
-
-func TestAskFile_SessionIsolation(t *testing.T) {
- mock := NewMockBackend()
- mock.askResponse = "response"
-
- sm := llm.NewSessionManager(mock)
- ask := NewAskFile(sm)
-
- fid1 := uint32(1)
- fid2 := uint32(2)
-
- // Write to fid1
- mock.askResponse = "response for fid1"
- ask.WriteFid(fid1, []byte("prompt1"), 0)
-
- // Write to fid2
- mock.askResponse = "response for fid2"
- ask.WriteFid(fid2, []byte("prompt2"), 0)
-
- // Read from fid1 - should get fid1's response
- buf := make([]byte, 100)
- n, _ := ask.ReadFid(fid1, buf, 0)
- response1 := string(buf[:n])
- if response1 != "response for fid1\n" {
- t.Errorf("fid1 ReadFid() = %q, want %q", response1, "response for fid1\n")
- }
-
- // Read from fid2 - should get fid2's response
- n, _ = ask.ReadFid(fid2, buf, 0)
- response2 := string(buf[:n])
- if response2 != "response for fid2\n" {
- t.Errorf("fid2 ReadFid() = %q, want %q", response2, "response for fid2\n")
- }
-}
-
-func TestAskFile_CloseFid(t *testing.T) {
- mock := NewMockBackend()
- mock.askResponse = "response"
-
- sm := llm.NewSessionManager(mock)
- ask := NewAskFile(sm)
-
- fid := uint32(1)
-
- // Write to create session
- ask.WriteFid(fid, []byte("test"), 0)
-
- // Session should exist
- session := sm.Get(fid)
- if session == nil {
- t.Fatal("session should exist after WriteFid")
- }
-
- // Close the fid
- ask.CloseFid(fid)
-
- // Session should be removed
- session = sm.Get(fid)
- if session != nil {
- t.Error("session should be removed after CloseFid")
- }
-}
-
-func TestAskFile_Stat(t *testing.T) {
- mock := NewMockBackend()
- mock.askResponse = "Hello!"
-
- sm := llm.NewSessionManager(mock)
- ask := NewAskFile(sm)
-
- // Stat without fid context returns 0 length
- stat := ask.Stat()
- if stat.Length != 0 {
- t.Errorf("Stat().Length = %d, want 0", stat.Length)
- }
-}
-
-func TestAskFile_ResponseNewline(t *testing.T) {
- mock := NewMockBackend()
- sm := llm.NewSessionManager(mock)
-
- fid := uint32(1)
-
- // Response without trailing newline
- mock.askResponse = "No newline"
- ask := NewAskFile(sm)
- ask.WriteFid(fid, []byte("test"), 0)
-
- buf := make([]byte, 100)
- n, _ := ask.ReadFid(fid, buf, 0)
- response := string(buf[:n])
-
- // Should have newline added
- if response[len(response)-1] != '\n' {
- t.Errorf("Response should end with newline, got %q", response)
- }
-
- // New session for second test
- fid2 := uint32(2)
-
- // Response with trailing newline already
- mock.askResponse = "Has newline\n"
- ask.WriteFid(fid2, []byte("test"), 0)
-
- n, _ = ask.ReadFid(fid2, buf, 0)
- response = string(buf[:n])
-
- // Should NOT double the newline
- if response != "Has newline\n" {
- t.Errorf("Response = %q, should not double newline", response)
- }
-}
-
-func TestCompactThreshold(t *testing.T) {
- // Verify the threshold constant
- if CompactThreshold != 0.80 {
- t.Errorf("CompactThreshold = %f, want 0.80", CompactThreshold)
- }
-}
D internal/llmfs/context.go => internal/llmfs/context.go +0 -81
@@ 1,81 0,0 @@
-package llmfs
-
-import (
- "io"
- "strings"
-
- "github.com/NERVsystems/llm9p/internal/llm"
- "github.com/NERVsystems/llm9p/internal/protocol"
-)
-
-// ContextFile exposes the conversation history.
-// It implements FidAwareFile to provide per-fid session isolation.
-// Read: returns JSON of conversation history for this fid's session
-// Write: appends a system message to this fid's session context
-type ContextFile struct {
- *protocol.BaseFile
- sm *llm.SessionManager
-}
-
-// NewContextFile creates the context file
-func NewContextFile(sm *llm.SessionManager) *ContextFile {
- return &ContextFile{
- BaseFile: protocol.NewBaseFile("context", 0666),
- sm: sm,
- }
-}
-
-// Read implements File.Read (fallback for non-fid-aware access)
-func (f *ContextFile) Read(p []byte, offset int64) (int, error) {
- // Without fid context, return empty
- return 0, io.EOF
-}
-
-// Write implements File.Write (fallback for non-fid-aware access)
-func (f *ContextFile) Write(p []byte, offset int64) (int, error) {
- // Without fid context, we can't add to a specific session
- return 0, protocol.ErrPermission
-}
-
-// ReadFid implements FidAwareFile.ReadFid
-func (f *ContextFile) ReadFid(fid uint32, p []byte, offset int64) (int, error) {
- session := f.sm.GetOrCreate(fid)
- content, err := session.MessagesJSON()
- if err != nil {
- return 0, err
- }
- // Add newline
- content = append(content, '\n')
-
- if offset >= int64(len(content)) {
- return 0, io.EOF
- }
- n := copy(p, content[offset:])
- return n, nil
-}
-
-// WriteFid implements FidAwareFile.WriteFid
-func (f *ContextFile) WriteFid(fid uint32, p []byte, offset int64) (int, error) {
- // Writing appends a system message to this fid's session context
- msg := strings.TrimSpace(string(p))
- if msg != "" {
- session := f.sm.GetOrCreate(fid)
- session.AddSystemMessage(msg)
- }
- return len(p), nil
-}
-
-// CloseFid implements FidAwareFile.CloseFid
-func (f *ContextFile) CloseFid(fid uint32) error {
- // No per-fid state to clean up for this file
- // (session cleanup is handled by AskFile)
- return nil
-}
-
-// Stat returns the file's metadata
-func (f *ContextFile) Stat() protocol.Stat {
- s := f.BaseFile.Stat()
- // Length is dynamic based on session, but without fid context we return 0
- s.Length = 0
- return s
-}
A internal/llmfs/metrics.go => internal/llmfs/metrics.go +123 -0
@@ 0,0 1,123 @@
+package llmfs
+
+import (
+ "fmt"
+ "io"
+ "sync"
+ "time"
+
+ "github.com/NERVsystems/llm9p/internal/llm"
+ "github.com/NERVsystems/llm9p/internal/protocol"
+)
+
+// Metrics tracks LLM performance statistics
+type Metrics struct {
+ mu sync.RWMutex
+ requestCount int64
+ totalInputToks int64
+ totalOutputToks int64
+ totalLatencyMs int64
+ lastLatencyMs int64
+ minLatencyMs int64
+ maxLatencyMs int64
+ lastRequestTime time.Time
+}
+
+// Global metrics instance
+var GlobalMetrics = &Metrics{
+ minLatencyMs: 999999,
+}
+
+// RecordRequest records a completed LLM request
+func (m *Metrics) RecordRequest(inputTokens, outputTokens int, latencyMs int64) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ m.requestCount++
+ m.totalInputToks += int64(inputTokens)
+ m.totalOutputToks += int64(outputTokens)
+ m.totalLatencyMs += latencyMs
+ m.lastLatencyMs = latencyMs
+ m.lastRequestTime = time.Now()
+
+ if latencyMs < m.minLatencyMs {
+ m.minLatencyMs = latencyMs
+ }
+ if latencyMs > m.maxLatencyMs {
+ m.maxLatencyMs = latencyMs
+ }
+}
+
+// Report returns a formatted metrics report
+func (m *Metrics) Report() string {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ if m.requestCount == 0 {
+ return "requests: 0\n"
+ }
+
+ avgLatencyMs := m.totalLatencyMs / m.requestCount
+ avgToksPerReq := (m.totalInputToks + m.totalOutputToks) / m.requestCount
+
+ return fmt.Sprintf(`requests: %d
+input_tokens: %d
+output_tokens: %d
+total_tokens: %d
+avg_tokens_per_request: %d
+last_latency_ms: %d
+avg_latency_ms: %d
+min_latency_ms: %d
+max_latency_ms: %d
+last_request: %s
+`,
+ m.requestCount,
+ m.totalInputToks,
+ m.totalOutputToks,
+ m.totalInputToks+m.totalOutputToks,
+ avgToksPerReq,
+ m.lastLatencyMs,
+ avgLatencyMs,
+ m.minLatencyMs,
+ m.maxLatencyMs,
+ m.lastRequestTime.Format(time.RFC3339),
+ )
+}
+
+// MetricsFile exposes performance metrics via 9P
+type MetricsFile struct {
+ *protocol.BaseFile
+ client llm.Backend
+}
+
+// NewMetricsFile creates the metrics file and registers the metrics callback
+func NewMetricsFile(client llm.Backend) *MetricsFile {
+ // Register our metrics callback with the llm package
+ llm.SetMetricsCallback(func(inputTokens, outputTokens int, latencyMs int64) {
+ GlobalMetrics.RecordRequest(inputTokens, outputTokens, latencyMs)
+ })
+
+ return &MetricsFile{
+ BaseFile: protocol.NewBaseFile("metrics", 0444),
+ client: client,
+ }
+}
+
+func (f *MetricsFile) Read(p []byte, offset int64) (int, error) {
+ content := GlobalMetrics.Report()
+ if offset >= int64(len(content)) {
+ return 0, io.EOF
+ }
+ n := copy(p, content[offset:])
+ return n, nil
+}
+
+func (f *MetricsFile) Write(p []byte, offset int64) (int, error) {
+ return 0, protocol.ErrPermission
+}
+
+func (f *MetricsFile) Stat() protocol.Stat {
+ s := f.BaseFile.Stat()
+ s.Length = uint64(len(GlobalMetrics.Report()))
+ return s
+}
M internal/llmfs/mock_backend_test.go => internal/llmfs/mock_backend_test.go +8 -0
@@ 103,6 103,14 @@ func (m *MockBackend) AskWithHistory(ctx context.Context, history []llm.Message,
return m.askResponse, tokens, nil
}
+func (m *MockBackend) AskWithRequest(ctx context.Context, req llm.AskRequest) (string, int, error) {
+ if m.askError != nil {
+ return "", 0, m.askError
+ }
+ tokens := len(req.Prompt) + len(m.askResponse)
+ return m.askResponse, tokens, nil
+}
+
func (m *MockBackend) StartStream(ctx context.Context, prompt string) error {
return fmt.Errorf("streaming not implemented in mock")
}
M internal/llmfs/new.go => internal/llmfs/new.go +26 -27
@@ 1,57 1,56 @@
package llmfs
import (
+ "fmt"
+ "io"
+
"github.com/NERVsystems/llm9p/internal/llm"
"github.com/NERVsystems/llm9p/internal/protocol"
)
-// NewFile is a write-only file that resets the conversation when written to.
-// It implements FidAwareFile to reset only the session for the writing fid.
+// NewFile is the session factory: /n/llm/new
+// Read creates a new session and returns its ID.
+// This follows the Plan 9 clone pattern (like /net/tcp/clone, Acme windows).
type NewFile struct {
*protocol.BaseFile
sm *llm.SessionManager
}
-// NewNewFile creates the new file
+// NewNewFile creates the new file (session factory).
func NewNewFile(sm *llm.SessionManager) *NewFile {
return &NewFile{
- BaseFile: protocol.NewBaseFile("new", 0222),
+ BaseFile: protocol.NewBaseFile("new", 0444),
sm: sm,
}
}
-// Read implements File.Read
+// Read creates a new session and returns its ID.
+// Each read creates a fresh session with default settings.
func (f *NewFile) Read(p []byte, offset int64) (int, error) {
- return 0, protocol.ErrPermission
-}
-
-// Write implements File.Write (fallback for non-fid-aware access)
-func (f *NewFile) Write(p []byte, offset int64) (int, error) {
- // Without fid context, we can't reset a specific session
- return 0, protocol.ErrPermission
-}
+ // Only create session on first read (offset 0)
+ // Subsequent reads at higher offsets just return the remaining data
+ if offset > 0 {
+ return 0, io.EOF
+ }
-// ReadFid implements FidAwareFile.ReadFid
-func (f *NewFile) ReadFid(fid uint32, p []byte, offset int64) (int, error) {
- return 0, protocol.ErrPermission
-}
+ // Create new session
+ id := f.sm.Create()
-// WriteFid implements FidAwareFile.WriteFid
-func (f *NewFile) WriteFid(fid uint32, p []byte, offset int64) (int, error) {
- // Reset only the session for this fid
- f.sm.Reset(fid)
- return len(p), nil
+ // Return session ID
+ content := fmt.Sprintf("%d\n", id)
+ n := copy(p, content)
+ return n, nil
}
-// CloseFid implements FidAwareFile.CloseFid
-func (f *NewFile) CloseFid(fid uint32) error {
- // No per-fid state to clean up for this file
- return nil
+// Write is not supported - this is a read-only factory.
+func (f *NewFile) Write(p []byte, offset int64) (int, error) {
+ return 0, protocol.ErrPermission
}
-// Stat returns the file's metadata
+// Stat returns the file's metadata.
func (f *NewFile) Stat() protocol.Stat {
s := f.BaseFile.Stat()
+ // Length unknown until read
s.Length = 0
return s
}
M internal/llmfs/root.go => internal/llmfs/root.go +18 -33
@@ 7,38 7,23 @@ import (
)
// NewRoot creates the root directory of the LLM filesystem.
-// It takes a SessionManager which provides per-fid session isolation
-// and access to the underlying backend for global settings.
+// This implements the clone-based session architecture (CSP compliant):
+//
+// /n/llm/
+// ├── new # Read to create session, returns ID
+// ├── 0/ # Session 0 (fully independent)
+// │ ├── ask
+// │ ├── context
+// │ ├── ctl
+// │ ├── model
+// │ ├── temperature
+// │ ├── system
+// │ ├── thinking
+// │ └── prefill
+// ├── 1/ # Session 1 (fully independent)
+// └── ...
+//
+// No global files. Each session is isolated with its own settings.
func NewRoot(sm *llm.SessionManager) protocol.Dir {
- backend := sm.Backend()
- root := protocol.NewStaticDir("llm")
-
- // Session-aware files (per-fid isolation)
- root.AddChild(NewAskFile(sm))
- root.AddChild(NewNewFile(sm))
- root.AddChild(NewContextFile(sm))
-
- // Global settings files (shared across all fids)
- root.AddChild(NewModelFile(backend))
- root.AddChild(NewTemperatureFile(backend))
- root.AddChild(NewSystemFile(backend))
- root.AddChild(NewThinkingFile(backend))
- root.AddChild(NewPrefillFile(backend))
-
- // Token tracking (uses backend's global counters)
- root.AddChild(NewTokensFile(backend))
- root.AddChild(NewUsageFile(backend))
- root.AddChild(NewMetricsFile(backend))
- root.AddChild(NewCompactFile(backend))
-
- // Static files
- root.AddChild(NewExampleFile())
-
- // Add stream directory (uses backend directly)
- streamDir := protocol.NewStaticDir("stream")
- streamDir.AddChild(NewStreamAskFile(backend))
- streamDir.AddChild(NewChunkFile(backend))
- root.AddChild(streamDir)
-
- return root
+ return NewSessionsDir(sm)
}
R internal/llmfs/ask.go => internal/llmfs/session_ask.go +38 -55
@@ 10,43 10,31 @@ import (
"github.com/NERVsystems/llm9p/internal/protocol"
)
-// CompactThreshold is the percentage of context limit at which auto-compaction triggers
-const CompactThreshold = 0.80
-
-// AskFile is the main interaction file - write a prompt, read the response.
-// It implements FidAwareFile to provide per-fid session isolation.
-type AskFile struct {
+// SessionAskFile is the ask file for a specific session: /n/llm/N/ask
+// Write a prompt, read the response.
+type SessionAskFile struct {
*protocol.BaseFile
sm *llm.SessionManager
+ id int
}
-// NewAskFile creates the ask file
-func NewAskFile(sm *llm.SessionManager) *AskFile {
- return &AskFile{
+// NewSessionAskFile creates an ask file for the given session.
+func NewSessionAskFile(sm *llm.SessionManager, id int) *SessionAskFile {
+ return &SessionAskFile{
BaseFile: protocol.NewBaseFile("ask", 0666),
sm: sm,
+ id: id,
}
}
-// Read implements File.Read (fallback for non-fid-aware access)
-func (f *AskFile) Read(p []byte, offset int64) (int, error) {
- // Without fid context, we can't return session-specific data
- // Return empty to indicate no data available
- return 0, io.EOF
-}
-
-// Write implements File.Write (fallback for non-fid-aware access)
-func (f *AskFile) Write(p []byte, offset int64) (int, error) {
- // Without fid context, we can't process the request properly
- return 0, protocol.ErrPermission
-}
+// Read returns the last response from this session.
+func (f *SessionAskFile) Read(p []byte, offset int64) (int, error) {
+ session := f.sm.Get(f.id)
+ if session == nil {
+ return 0, protocol.ErrNotFound
+ }
-// ReadFid implements FidAwareFile.ReadFid
-func (f *AskFile) ReadFid(fid uint32, p []byte, offset int64) (int, error) {
- session := f.sm.GetOrCreate(fid)
content := session.LastResponse()
-
- // Add newline if not present
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
@@ 54,53 42,48 @@ func (f *AskFile) ReadFid(fid uint32, p []byte, offset int64) (int, error) {
if offset >= int64(len(content)) {
return 0, io.EOF
}
+
n := copy(p, content[offset:])
return n, nil
}
-// WriteFid implements FidAwareFile.WriteFid
-func (f *AskFile) WriteFid(fid uint32, p []byte, offset int64) (int, error) {
+// Write sends a prompt to the LLM using this session's settings.
+func (f *SessionAskFile) Write(p []byte, offset int64) (int, error) {
+ log.Printf("llm9p: SessionAskFile.Write session=%d len=%d", f.id, len(p))
+
prompt := strings.TrimSpace(string(p))
if prompt == "" {
return len(p), nil // Empty write is a no-op
}
- ctx := context.Background()
-
- // Check if we need to auto-compact before processing
- session := f.sm.GetOrCreate(fid)
- tokens := session.TotalTokens()
- limit := f.sm.ContextLimit()
- threshold := int(float64(limit) * CompactThreshold)
+ log.Printf("llm9p: SessionAskFile.Write prompt: %s", prompt[:min(len(prompt), 50)])
- if tokens > threshold {
- log.Printf("llm9p: fid %d at %d/%d tokens (%.0f%% threshold) - consider resetting",
- fid, tokens, limit, CompactThreshold*100)
- // Note: Per-session compaction would require a different approach
- // For now, just log a warning. Session reset via /new is the solution.
- }
-
- // Make the API call using the session
- _, err := f.sm.Ask(ctx, fid, prompt)
+ ctx := context.Background()
+ response, err := f.sm.Ask(ctx, f.id, prompt)
if err != nil {
+ log.Printf("llm9p: SessionAskFile.Write error: %v", err)
// Error is stored in session.LastResponse by SessionManager
return len(p), nil // Return success so client knows write completed
}
+ log.Printf("llm9p: SessionAskFile.Write success, response len=%d", len(response))
return len(p), nil
}
-// CloseFid implements FidAwareFile.CloseFid
-func (f *AskFile) CloseFid(fid uint32) error {
- // Clean up the session when the fid is clunked
- f.sm.Remove(fid)
- return nil
-}
-
-// Stat returns the file's metadata
-func (f *AskFile) Stat() protocol.Stat {
+// Stat returns the file's metadata.
+func (f *SessionAskFile) Stat() protocol.Stat {
s := f.BaseFile.Stat()
- // Length is dynamic based on session, but without fid context we return 0
- s.Length = 0
+ // Length is dynamic based on last response
+ session := f.sm.Get(f.id)
+ if session != nil {
+ s.Length = uint64(len(session.LastResponse()))
+ }
return s
}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
A internal/llmfs/session_context.go => internal/llmfs/session_context.go +60 -0
@@ 0,0 1,60 @@
+package llmfs
+
+import (
+ "io"
+
+ "github.com/NERVsystems/llm9p/internal/llm"
+ "github.com/NERVsystems/llm9p/internal/protocol"
+)
+
+// SessionContextFile exposes the conversation history: /n/llm/N/context
+// Read returns JSON of the conversation history.
+type SessionContextFile struct {
+ *protocol.BaseFile
+ sm *llm.SessionManager
+ id int
+}
+
+// NewSessionContextFile creates a context file for the given session.
+func NewSessionContextFile(sm *llm.SessionManager, id int) *SessionContextFile {
+ return &SessionContextFile{
+ BaseFile: protocol.NewBaseFile("context", 0444),
+ sm: sm,
+ id: id,
+ }
+}
+
+// Read returns the conversation history as JSON.
+func (f *SessionContextFile) Read(p []byte, offset int64) (int, error) {
+ session := f.sm.Get(f.id)
+ if session == nil {
+ return 0, protocol.ErrNotFound
+ }
+
+ content, err := session.MessagesJSON()
+ if err != nil {
+ return 0, err
+ }
+ // Add newline
+ content = append(content, '\n')
+
+ if offset >= int64(len(content)) {
+ return 0, io.EOF
+ }
+
+ n := copy(p, content[offset:])
+ return n, nil
+}
+
+// Stat returns the file's metadata.
+func (f *SessionContextFile) Stat() protocol.Stat {
+ s := f.BaseFile.Stat()
+ session := f.sm.Get(f.id)
+ if session != nil {
+ content, err := session.MessagesJSON()
+ if err == nil {
+ s.Length = uint64(len(content) + 1)
+ }
+ }
+ return s
+}
A internal/llmfs/session_ctl.go => internal/llmfs/session_ctl.go +52 -0
@@ 0,0 1,52 @@
+package llmfs
+
+import (
+ "io"
+ "strings"
+
+ "github.com/NERVsystems/llm9p/internal/llm"
+ "github.com/NERVsystems/llm9p/internal/protocol"
+)
+
+// SessionCtlFile is the control file for a session: /n/llm/N/ctl
+// Supports commands: "reset" (clear history), "close" (remove session)
+type SessionCtlFile struct {
+ *protocol.BaseFile
+ sm *llm.SessionManager
+ id int
+}
+
+// NewSessionCtlFile creates a ctl file for the given session.
+func NewSessionCtlFile(sm *llm.SessionManager, id int) *SessionCtlFile {
+ return &SessionCtlFile{
+ BaseFile: protocol.NewBaseFile("ctl", 0222),
+ sm: sm,
+ id: id,
+ }
+}
+
+// Read returns empty for the control file.
+func (f *SessionCtlFile) Read(p []byte, offset int64) (int, error) {
+ return 0, io.EOF
+}
+
+// Write processes control commands.
+func (f *SessionCtlFile) Write(p []byte, offset int64) (int, error) {
+ cmd := strings.TrimSpace(string(p))
+
+ switch cmd {
+ case "reset":
+ f.sm.Reset(f.id)
+ case "close":
+ f.sm.Close(f.id)
+ default:
+ return 0, protocol.Error("unknown command: " + cmd)
+ }
+
+ return len(p), nil
+}
+
+// Stat returns the file's metadata.
+func (f *SessionCtlFile) Stat() protocol.Stat {
+ return f.BaseFile.Stat()
+}
A internal/llmfs/session_dir.go => internal/llmfs/session_dir.go +180 -0
@@ 0,0 1,180 @@
+package llmfs
+
+import (
+ "fmt"
+ "io"
+ "strconv"
+
+ "github.com/NERVsystems/llm9p/internal/llm"
+ "github.com/NERVsystems/llm9p/internal/protocol"
+)
+
+// SessionDir represents a single session directory: /n/llm/N/
+// Contains: ask, context, ctl, model, temperature, system, thinking, prefill
+type SessionDir struct {
+ *protocol.BaseFile
+ sm *llm.SessionManager
+ id int
+}
+
+// NewSessionDir creates a session directory for the given session ID.
+func NewSessionDir(sm *llm.SessionManager, id int) *SessionDir {
+ return &SessionDir{
+ BaseFile: protocol.NewBaseFile(strconv.Itoa(id), protocol.DMDIR|0555),
+ sm: sm,
+ id: id,
+ }
+}
+
+// Children returns the files in this session directory.
+func (d *SessionDir) Children() []protocol.File {
+ session := d.sm.Get(d.id)
+ if session == nil {
+ return nil
+ }
+
+ return []protocol.File{
+ NewSessionAskFile(d.sm, d.id),
+ NewSessionContextFile(d.sm, d.id),
+ NewSessionCtlFile(d.sm, d.id),
+ NewSessionModelFile(d.sm, d.id),
+ NewSessionTemperatureFile(d.sm, d.id),
+ NewSessionSystemFile(d.sm, d.id),
+ NewSessionThinkingFile(d.sm, d.id),
+ NewSessionPrefillFile(d.sm, d.id),
+ }
+}
+
+// Lookup finds a child file by name.
+func (d *SessionDir) Lookup(name string) (protocol.File, error) {
+ session := d.sm.Get(d.id)
+ if session == nil {
+ return nil, protocol.ErrNotFound
+ }
+
+ switch name {
+ case "ask":
+ return NewSessionAskFile(d.sm, d.id), nil
+ case "context":
+ return NewSessionContextFile(d.sm, d.id), nil
+ case "ctl":
+ return NewSessionCtlFile(d.sm, d.id), nil
+ case "model":
+ return NewSessionModelFile(d.sm, d.id), nil
+ case "temperature":
+ return NewSessionTemperatureFile(d.sm, d.id), nil
+ case "system":
+ return NewSessionSystemFile(d.sm, d.id), nil
+ case "thinking":
+ return NewSessionThinkingFile(d.sm, d.id), nil
+ case "prefill":
+ return NewSessionPrefillFile(d.sm, d.id), nil
+ default:
+ return nil, protocol.ErrNotFound
+ }
+}
+
+// Read returns directory listing as packed stat entries.
+func (d *SessionDir) Read(p []byte, offset int64) (int, error) {
+ var buf []byte
+ for _, f := range d.Children() {
+ stat := f.Stat()
+ entry := make([]byte, 256)
+ n := stat.Encode(entry)
+ buf = append(buf, entry[:n]...)
+ }
+
+ if offset >= int64(len(buf)) {
+ return 0, io.EOF
+ }
+
+ n := copy(p, buf[offset:])
+ return n, nil
+}
+
+// Stat returns the directory's metadata.
+func (d *SessionDir) Stat() protocol.Stat {
+ s := d.BaseFile.Stat()
+ s.Qid.Type = protocol.QTDIR
+ return s
+}
+
+// SessionsDir is the root /n/llm directory.
+// Contains only the "new" file plus dynamically created session directories.
+type SessionsDir struct {
+ *protocol.BaseFile
+ sm *llm.SessionManager
+ newFile *NewFile
+}
+
+// NewSessionsDir creates the root LLM directory.
+func NewSessionsDir(sm *llm.SessionManager) *SessionsDir {
+ return &SessionsDir{
+ BaseFile: protocol.NewBaseFile("llm", protocol.DMDIR|0555),
+ sm: sm,
+ newFile: NewNewFile(sm),
+ }
+}
+
+// Children returns the files in the root directory.
+// This includes "new" plus all active session directories.
+func (d *SessionsDir) Children() []protocol.File {
+ children := []protocol.File{d.newFile}
+
+ // Add session directories for all active sessions
+ for _, id := range d.sm.ListSessions() {
+ children = append(children, NewSessionDir(d.sm, id))
+ }
+
+ return children
+}
+
+// Lookup finds a child by name.
+func (d *SessionsDir) Lookup(name string) (protocol.File, error) {
+ // Check for "new" file
+ if name == "new" {
+ return d.newFile, nil
+ }
+
+ // Try to parse as session ID
+ id, err := strconv.Atoi(name)
+ if err != nil {
+ return nil, protocol.ErrNotFound
+ }
+
+ // Check if session exists
+ session := d.sm.Get(id)
+ if session == nil {
+ return nil, protocol.ErrNotFound
+ }
+
+ return NewSessionDir(d.sm, id), nil
+}
+
+// Read returns directory listing as packed stat entries.
+func (d *SessionsDir) Read(p []byte, offset int64) (int, error) {
+ var buf []byte
+ for _, f := range d.Children() {
+ stat := f.Stat()
+ entry := make([]byte, 256)
+ n := stat.Encode(entry)
+ buf = append(buf, entry[:n]...)
+ }
+
+ if offset >= int64(len(buf)) {
+ return 0, io.EOF
+ }
+
+ n := copy(p, buf[offset:])
+ return n, nil
+}
+
+// Stat returns the directory's metadata.
+func (d *SessionsDir) Stat() protocol.Stat {
+ s := d.BaseFile.Stat()
+ s.Qid.Type = protocol.QTDIR
+ return s
+}
+
+// Silence unused import warning
+var _ = fmt.Sprint
A internal/llmfs/session_settings.go => internal/llmfs/session_settings.go +313 -0
@@ 0,0 1,313 @@
+package llmfs
+
+import (
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+
+ "github.com/NERVsystems/llm9p/internal/llm"
+ "github.com/NERVsystems/llm9p/internal/protocol"
+)
+
+// SessionModelFile controls the model for a session: /n/llm/N/model
+type SessionModelFile struct {
+ *protocol.BaseFile
+ sm *llm.SessionManager
+ id int
+}
+
+// NewSessionModelFile creates a model file for the given session.
+func NewSessionModelFile(sm *llm.SessionManager, id int) *SessionModelFile {
+ return &SessionModelFile{
+ BaseFile: protocol.NewBaseFile("model", 0666),
+ sm: sm,
+ id: id,
+ }
+}
+
+// Read returns the current model name.
+func (f *SessionModelFile) Read(p []byte, offset int64) (int, error) {
+ session := f.sm.Get(f.id)
+ if session == nil {
+ return 0, protocol.ErrNotFound
+ }
+
+ content := session.Model() + "\n"
+ if offset >= int64(len(content)) {
+ return 0, io.EOF
+ }
+ return copy(p, content[offset:]), nil
+}
+
+// Write sets the model name.
+func (f *SessionModelFile) Write(p []byte, offset int64) (int, error) {
+ session := f.sm.Get(f.id)
+ if session == nil {
+ return 0, protocol.ErrNotFound
+ }
+
+ model := strings.TrimSpace(string(p))
+ if model != "" {
+ session.SetModel(model)
+ }
+ return len(p), nil
+}
+
+// Stat returns the file's metadata.
+func (f *SessionModelFile) Stat() protocol.Stat {
+ s := f.BaseFile.Stat()
+ session := f.sm.Get(f.id)
+ if session != nil {
+ s.Length = uint64(len(session.Model()) + 1)
+ }
+ return s
+}
+
+// SessionTemperatureFile controls the temperature for a session: /n/llm/N/temperature
+type SessionTemperatureFile struct {
+ *protocol.BaseFile
+ sm *llm.SessionManager
+ id int
+}
+
+// NewSessionTemperatureFile creates a temperature file for the given session.
+func NewSessionTemperatureFile(sm *llm.SessionManager, id int) *SessionTemperatureFile {
+ return &SessionTemperatureFile{
+ BaseFile: protocol.NewBaseFile("temperature", 0666),
+ sm: sm,
+ id: id,
+ }
+}
+
+// Read returns the current temperature.
+func (f *SessionTemperatureFile) Read(p []byte, offset int64) (int, error) {
+ session := f.sm.Get(f.id)
+ if session == nil {
+ return 0, protocol.ErrNotFound
+ }
+
+ content := fmt.Sprintf("%.2f\n", session.Temperature())
+ if offset >= int64(len(content)) {
+ return 0, io.EOF
+ }
+ return copy(p, content[offset:]), nil
+}
+
+// Write sets the temperature.
+func (f *SessionTemperatureFile) Write(p []byte, offset int64) (int, error) {
+ session := f.sm.Get(f.id)
+ if session == nil {
+ return 0, protocol.ErrNotFound
+ }
+
+ temp, err := strconv.ParseFloat(strings.TrimSpace(string(p)), 64)
+ if err != nil {
+ return 0, protocol.Error("invalid temperature: " + err.Error())
+ }
+ if temp < 0.0 || temp > 2.0 {
+ return 0, protocol.Error("temperature must be between 0.0 and 2.0")
+ }
+ session.SetTemperature(temp)
+ return len(p), nil
+}
+
+// Stat returns the file's metadata.
+func (f *SessionTemperatureFile) Stat() protocol.Stat {
+ s := f.BaseFile.Stat()
+ session := f.sm.Get(f.id)
+ if session != nil {
+ s.Length = uint64(len(fmt.Sprintf("%.2f\n", session.Temperature())))
+ }
+ return s
+}
+
+// SessionSystemFile controls the system prompt for a session: /n/llm/N/system
+type SessionSystemFile struct {
+ *protocol.BaseFile
+ sm *llm.SessionManager
+ id int
+}
+
+// NewSessionSystemFile creates a system file for the given session.
+func NewSessionSystemFile(sm *llm.SessionManager, id int) *SessionSystemFile {
+ return &SessionSystemFile{
+ BaseFile: protocol.NewBaseFile("system", 0666),
+ sm: sm,
+ id: id,
+ }
+}
+
+// Read returns the current system prompt.
+func (f *SessionSystemFile) Read(p []byte, offset int64) (int, error) {
+ session := f.sm.Get(f.id)
+ if session == nil {
+ return 0, protocol.ErrNotFound
+ }
+
+ content := session.SystemPrompt()
+ if content != "" && !strings.HasSuffix(content, "\n") {
+ content += "\n"
+ }
+ if offset >= int64(len(content)) {
+ return 0, io.EOF
+ }
+ return copy(p, content[offset:]), nil
+}
+
+// Write sets the system prompt.
+func (f *SessionSystemFile) Write(p []byte, offset int64) (int, error) {
+ session := f.sm.Get(f.id)
+ if session == nil {
+ return 0, protocol.ErrNotFound
+ }
+
+ prompt := strings.TrimSpace(string(p))
+ session.SetSystemPrompt(prompt)
+ return len(p), nil
+}
+
+// Stat returns the file's metadata.
+func (f *SessionSystemFile) Stat() protocol.Stat {
+ s := f.BaseFile.Stat()
+ session := f.sm.Get(f.id)
+ if session != nil {
+ content := session.SystemPrompt()
+ if content != "" {
+ s.Length = uint64(len(content) + 1)
+ }
+ }
+ return s
+}
+
+// SessionThinkingFile controls the thinking token budget: /n/llm/N/thinking
+type SessionThinkingFile struct {
+ *protocol.BaseFile
+ sm *llm.SessionManager
+ id int
+}
+
+// NewSessionThinkingFile creates a thinking file for the given session.
+func NewSessionThinkingFile(sm *llm.SessionManager, id int) *SessionThinkingFile {
+ return &SessionThinkingFile{
+ BaseFile: protocol.NewBaseFile("thinking", 0666),
+ sm: sm,
+ id: id,
+ }
+}
+
+// Read returns the current thinking token budget.
+func (f *SessionThinkingFile) Read(p []byte, offset int64) (int, error) {
+ session := f.sm.Get(f.id)
+ if session == nil {
+ return 0, protocol.ErrNotFound
+ }
+
+ tokens := session.ThinkingTokens()
+ var content string
+ switch {
+ case tokens < 0:
+ content = "max\n"
+ case tokens == 0:
+ content = "disabled\n"
+ default:
+ content = fmt.Sprintf("%d\n", tokens)
+ }
+
+ if offset >= int64(len(content)) {
+ return 0, io.EOF
+ }
+ return copy(p, content[offset:]), nil
+}
+
+// Write sets the thinking token budget.
+func (f *SessionThinkingFile) Write(p []byte, offset int64) (int, error) {
+ session := f.sm.Get(f.id)
+ if session == nil {
+ return 0, protocol.ErrNotFound
+ }
+
+ value := strings.TrimSpace(string(p))
+ switch value {
+ case "max", "-1":
+ session.SetThinkingTokens(-1)
+ case "disabled", "off", "0":
+ session.SetThinkingTokens(0)
+ default:
+ tokens, err := strconv.Atoi(value)
+ if err != nil {
+ return 0, protocol.Error("invalid thinking budget: " + err.Error())
+ }
+ session.SetThinkingTokens(tokens)
+ }
+ return len(p), nil
+}
+
+// Stat returns the file's metadata.
+func (f *SessionThinkingFile) Stat() protocol.Stat {
+ s := f.BaseFile.Stat()
+ // Estimate length
+ s.Length = 16
+ return s
+}
+
+// SessionPrefillFile controls the response prefill: /n/llm/N/prefill
+type SessionPrefillFile struct {
+ *protocol.BaseFile
+ sm *llm.SessionManager
+ id int
+}
+
+// NewSessionPrefillFile creates a prefill file for the given session.
+func NewSessionPrefillFile(sm *llm.SessionManager, id int) *SessionPrefillFile {
+ return &SessionPrefillFile{
+ BaseFile: protocol.NewBaseFile("prefill", 0666),
+ sm: sm,
+ id: id,
+ }
+}
+
+// Read returns the current prefill string.
+func (f *SessionPrefillFile) Read(p []byte, offset int64) (int, error) {
+ session := f.sm.Get(f.id)
+ if session == nil {
+ return 0, protocol.ErrNotFound
+ }
+
+ content := session.Prefill()
+ if content != "" && !strings.HasSuffix(content, "\n") {
+ content += "\n"
+ }
+ if offset >= int64(len(content)) {
+ return 0, io.EOF
+ }
+ return copy(p, content[offset:]), nil
+}
+
+// Write sets the prefill string.
+func (f *SessionPrefillFile) Write(p []byte, offset int64) (int, error) {
+ session := f.sm.Get(f.id)
+ if session == nil {
+ return 0, protocol.ErrNotFound
+ }
+
+ // Don't trim - prefill may have intentional trailing space
+ prefill := string(p)
+ // But do remove trailing newline since shell adds it
+ prefill = strings.TrimSuffix(prefill, "\n")
+ session.SetPrefill(prefill)
+ return len(p), nil
+}
+
+// Stat returns the file's metadata.
+func (f *SessionPrefillFile) Stat() protocol.Stat {
+ s := f.BaseFile.Stat()
+ session := f.sm.Get(f.id)
+ if session != nil {
+ content := session.Prefill()
+ if content != "" {
+ s.Length = uint64(len(content) + 1)
+ }
+ }
+ return s
+}
M internal/protocol/fs.go => internal/protocol/fs.go +0 -16
@@ 36,22 36,6 @@ type Dir interface {
Lookup(name string) (File, error)
}
-// FidAwareFile is implemented by files that need per-fid state.
-// When a file implements this interface, the server will call the
-// fid-aware methods instead of the standard File methods.
-type FidAwareFile interface {
- File
-
- // ReadFid reads from the file with fid context
- ReadFid(fid uint32, p []byte, offset int64) (n int, err error)
-
- // WriteFid writes to the file with fid context
- WriteFid(fid uint32, p []byte, offset int64) (n int, err error)
-
- // CloseFid is called when a fid is clunked
- CloseFid(fid uint32) error
-}
-
// pathCounter generates unique path IDs for qids
var pathCounter uint64
M internal/protocol/server.go => internal/protocol/server.go +2 -23
@@ 272,15 272,7 @@ func (s *Server) handleRead(state *clientState, payload []byte, buf []byte) ([]b
}
data := make([]byte, count)
- var n int
-
- // Check for fid-aware file
- if faf, ok := file.(FidAwareFile); ok {
- n, err = faf.ReadFid(msg.Fid, data, int64(msg.Offset))
- } else {
- n, err = file.Read(data, int64(msg.Offset))
- }
-
+ n, err := file.Read(data, int64(msg.Offset))
if err != nil && err != io.EOF {
return s.errorResponse(buf, err.Error())
}
@@ 301,15 293,7 @@ func (s *Server) handleWrite(state *clientState, payload []byte, buf []byte) ([]
return s.errorResponse(buf, ErrBadFid.Error())
}
- var n int
-
- // Check for fid-aware file
- if faf, ok := file.(FidAwareFile); ok {
- n, err = faf.WriteFid(msg.Fid, msg.Data, int64(msg.Offset))
- } else {
- n, err = file.Write(msg.Data, int64(msg.Offset))
- }
-
+ n, err := file.Write(msg.Data, int64(msg.Offset))
if err != nil {
return s.errorResponse(buf, err.Error())
}
@@ 330,11 314,6 @@ func (s *Server) handleClunk(state *clientState, payload []byte, buf []byte) ([]
return s.errorResponse(buf, ErrBadFid.Error())
}
- // Call CloseFid for fid-aware files to clean up per-fid state
- if faf, ok := file.(FidAwareFile); ok {
- faf.CloseFid(msg.Fid)
- }
-
file.Close()
delete(state.fids, msg.Fid)