~kris/9p

llm9p

a3dc06aa1a37febcbe7d8d340be3d9b5a2a3f6ea — pdfinn 7 months ago ed43a61
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/<id>/
- 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 <noreply@anthropic.com>
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)