From a3dc06aa1a37febcbe7d8d340be3d9b5a2a3f6ea Mon Sep 17 00:00:00 2001 From: pdfinn Date: Sat, 7 Feb 2026 07:18:48 +0700 Subject: [PATCH] feat(llm9p): Implement clone-based session architecture Replace per-fid session model with Plan 9 clone pattern: - Reading /n/llm/new creates a session and returns its ID - Each session gets its own directory: /n/llm// - Per-session files: ask, ctl, model, system, thinking, context, metrics - AskWithRequest method for stateless CSP-style LLM calls - Session settings (model, temperature, thinking) are per-session - Remove old ask.go, context.go in favor of session-scoped files Co-Authored-By: Claude Opus 4.6 --- internal/llm/backend.go | 3 + internal/llm/cli_client.go | 96 ++++++++- internal/llm/client.go | 101 +++++++++ internal/llm/session.go | 305 ++++++++++++++++++++------- internal/llmfs/ask.go | 106 ---------- internal/llmfs/ask_test.go | 227 -------------------- internal/llmfs/context.go | 81 ------- internal/llmfs/metrics.go | 123 +++++++++++ internal/llmfs/mock_backend_test.go | 8 + internal/llmfs/new.go | 53 +++-- internal/llmfs/root.go | 51 ++--- internal/llmfs/session_ask.go | 89 ++++++++ internal/llmfs/session_context.go | 60 ++++++ internal/llmfs/session_ctl.go | 52 +++++ internal/llmfs/session_dir.go | 180 ++++++++++++++++ internal/llmfs/session_settings.go | 313 ++++++++++++++++++++++++++++ internal/protocol/fs.go | 16 -- internal/protocol/server.go | 25 +-- 18 files changed, 1305 insertions(+), 584 deletions(-) delete mode 100644 internal/llmfs/ask.go delete mode 100644 internal/llmfs/ask_test.go delete mode 100644 internal/llmfs/context.go create mode 100644 internal/llmfs/metrics.go create mode 100644 internal/llmfs/session_ask.go create mode 100644 internal/llmfs/session_context.go create mode 100644 internal/llmfs/session_ctl.go create mode 100644 internal/llmfs/session_dir.go create mode 100644 internal/llmfs/session_settings.go diff --git a/internal/llm/backend.go b/internal/llm/backend.go index 30b13cf19197c58f5f4ada8450115084aad4c590..898634b90f2b144150b2f7032dd5efa333387972 100644 --- a/internal/llm/backend.go +++ b/internal/llm/backend.go @@ -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 diff --git a/internal/llm/cli_client.go b/internal/llm/cli_client.go index 22fd7860f99112fdf44101b95aca2c9783b965be..43c885930abae03c7419b2dcc97da9edd3c03cc8 100644 --- a/internal/llm/cli_client.go +++ b/internal/llm/cli_client.go @@ -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 +} diff --git a/internal/llm/client.go b/internal/llm/client.go index 02cedced96fbc4b5a8138a454efa9040d6f682a5..d7457e06df34833dbf327bea64c331c7f868b450 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -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 +} diff --git a/internal/llm/session.go b/internal/llm/session.go index 4cbc38256f2268c36750a6691390bf487705a150..a9ca83a5be01986d74212ab32c3a631aff76a298 100644 --- a/internal/llm/session.go +++ b/internal/llm/session.go @@ -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" +) diff --git a/internal/llmfs/ask.go b/internal/llmfs/ask.go deleted file mode 100644 index 95c477cbb8d204fae0b54dbfc6d784f64f7318b3..0000000000000000000000000000000000000000 --- a/internal/llmfs/ask.go +++ /dev/null @@ -1,106 +0,0 @@ -package llmfs - -import ( - "context" - "io" - "log" - "strings" - - "github.com/NERVsystems/llm9p/internal/llm" - "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 { - *protocol.BaseFile - sm *llm.SessionManager -} - -// NewAskFile creates the ask file -func NewAskFile(sm *llm.SessionManager) *AskFile { - return &AskFile{ - BaseFile: protocol.NewBaseFile("ask", 0666), - sm: sm, - } -} - -// 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 -} - -// 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" - } - - 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) { - 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) - - 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) - if err != nil { - // Error is stored in session.LastResponse by SessionManager - return len(p), nil // Return success so client knows write completed - } - - 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 { - s := f.BaseFile.Stat() - // Length is dynamic based on session, but without fid context we return 0 - s.Length = 0 - return s -} diff --git a/internal/llmfs/ask_test.go b/internal/llmfs/ask_test.go deleted file mode 100644 index d179d1b9ae4b1321344cbd2e1aa90634cbf729a7..0000000000000000000000000000000000000000 --- a/internal/llmfs/ask_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/llmfs/context.go b/internal/llmfs/context.go deleted file mode 100644 index 32ed57a9b9356323acde975b64fdca2088e494c9..0000000000000000000000000000000000000000 --- a/internal/llmfs/context.go +++ /dev/null @@ -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 -} diff --git a/internal/llmfs/metrics.go b/internal/llmfs/metrics.go new file mode 100644 index 0000000000000000000000000000000000000000..afa7888a7bff935e4c69edf10f68abe319d53b7b --- /dev/null +++ b/internal/llmfs/metrics.go @@ -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 +} diff --git a/internal/llmfs/mock_backend_test.go b/internal/llmfs/mock_backend_test.go index e9a5d6ae76efbc7b9f1301d9bfdb9a8834e04018..567180aa3a35a7e90d0a873bcb5fcfbcb087dad0 100644 --- a/internal/llmfs/mock_backend_test.go +++ b/internal/llmfs/mock_backend_test.go @@ -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") } diff --git a/internal/llmfs/new.go b/internal/llmfs/new.go index d11cf5c03e720f0f9797e3de0ee3e06587b51ca0..e3b794eefaaf0f905a4abba65f655a0fccdd343e 100644 --- a/internal/llmfs/new.go +++ b/internal/llmfs/new.go @@ -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 } diff --git a/internal/llmfs/root.go b/internal/llmfs/root.go index 066676e8c3b7d395358fcf40a84b16372110074d..b2f54cef391277de358ad8cc51e5afa24da22714 100644 --- a/internal/llmfs/root.go +++ b/internal/llmfs/root.go @@ -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) } diff --git a/internal/llmfs/session_ask.go b/internal/llmfs/session_ask.go new file mode 100644 index 0000000000000000000000000000000000000000..252386d48ed119020dc95623462506c30b90f3fa --- /dev/null +++ b/internal/llmfs/session_ask.go @@ -0,0 +1,89 @@ +package llmfs + +import ( + "context" + "io" + "log" + "strings" + + "github.com/NERVsystems/llm9p/internal/llm" + "github.com/NERVsystems/llm9p/internal/protocol" +) + +// 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 +} + +// 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 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 + } + + content := session.LastResponse() + if content != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + + if offset >= int64(len(content)) { + return 0, io.EOF + } + + n := copy(p, content[offset:]) + return n, nil +} + +// 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 + } + + log.Printf("llm9p: SessionAskFile.Write prompt: %s", prompt[:min(len(prompt), 50)]) + + 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 +} + +// Stat returns the file's metadata. +func (f *SessionAskFile) Stat() protocol.Stat { + s := f.BaseFile.Stat() + // 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 +} diff --git a/internal/llmfs/session_context.go b/internal/llmfs/session_context.go new file mode 100644 index 0000000000000000000000000000000000000000..0b076731b0d2728dfde4fb1427b2d0bc209110f1 --- /dev/null +++ b/internal/llmfs/session_context.go @@ -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 +} diff --git a/internal/llmfs/session_ctl.go b/internal/llmfs/session_ctl.go new file mode 100644 index 0000000000000000000000000000000000000000..ddac1d1cbca956b223102664095aea764f6da1ca --- /dev/null +++ b/internal/llmfs/session_ctl.go @@ -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() +} diff --git a/internal/llmfs/session_dir.go b/internal/llmfs/session_dir.go new file mode 100644 index 0000000000000000000000000000000000000000..b09968ad93ed49d07eb5d2f97d649661c8af365a --- /dev/null +++ b/internal/llmfs/session_dir.go @@ -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 diff --git a/internal/llmfs/session_settings.go b/internal/llmfs/session_settings.go new file mode 100644 index 0000000000000000000000000000000000000000..a4b9eb1ee0a77095771190973bee6b08845f848b --- /dev/null +++ b/internal/llmfs/session_settings.go @@ -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 +} diff --git a/internal/protocol/fs.go b/internal/protocol/fs.go index 3d7608a916a2d954afc3c725f6665728012fda47..e137af1e1b5f9feb873a491228890f42138b4494 100644 --- a/internal/protocol/fs.go +++ b/internal/protocol/fs.go @@ -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 diff --git a/internal/protocol/server.go b/internal/protocol/server.go index 5eb0f6e25a2017e9c8558352f62b8ce7dc0fcf39..123c52fb9f692018cd9b490818907379618667db 100644 --- a/internal/protocol/server.go +++ b/internal/protocol/server.go @@ -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)