📄 repomix-output.xml
/home/palash/git/local-ai/repomix-output.xml
Language: xml • Lines: 19374
This file is a merged representation of the entire codebase, combined into a single document by Repomix.

<file_summary>
This section contains a summary of this file.

<purpose>
This file contains a packed representation of the entire repository's contents.
It is designed to be easily consumable by AI systems for analysis, code review,
or other automated processes.
</purpose>

<file_format>
The content is organized as follows:
1. This summary section
2. Repository information
3. Directory structure
4. Repository files (if enabled)
5. Multiple file entries, each consisting of:
  - File path as an attribute
  - Full contents of the file
</file_format>

<usage_guidelines>
- This file should be treated as read-only. Any changes should be made to the
  original repository files, not this packed version.
- When processing this file, use the file path to distinguish
  between different files in the repository.
- Be aware that this file may contain sensitive information. Handle it with
  the same level of security as you would the original repository.
</usage_guidelines>

<notes>
- Some files may have been excluded based on .gitignore rules and Repomix's configuration
- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files
- Files matching patterns in .gitignore are excluded
- Files matching default ignore patterns are excluded
- Files are sorted by Git change count (files with more changes are at the bottom)
</notes>

</file_summary>

<directory_structure>
prompts/
  critique.txt
  editor.txt
  genre_checklists.json
  genre_persona_map.json
  moderator.txt
  persona_pool.json
  self_chat.txt
  tasks.json
scripts/
  authentik_bootstrap.py
  gcp_heartbeat_server.py
  gcp-heartbeat.service
searxng/
  settings.yml
server/
  features/
    __init__.py
    context.py
    critic.py
    images.py
    llm.py
    monitoring.py
    orchestration.py
    sessions.py
    shares.py
    state.py
    tasks_db.py
    themes_db.py
    tools.py
    users.py
  api.py
  auth.py
  config.py
  dotenv.py
  read_file.py
  track_dashboard.py
src/
  components/
    ChatArea.jsx
    ImageLightbox.jsx
    InputBar.jsx
    LocationPrompt.jsx
    Message.jsx
    ModelBar.jsx
    OverloadWarning.jsx
    PublicShareView.jsx
    Sidebar.jsx
    StatusBox.jsx
    TaskPanel.jsx
  api.js
  App.css
  App.jsx
  main.jsx
  utils.js
.gitignore
.repomixignore
authentik-compose.yaml
chat-webui.py
docker-compose.yaml
gcp_nginx.conf
genre_creator.py
index.html
LICENSE
local_cloud.sh
markdown_hosting.py
package.json
README.md
requirements.txt
restart_services.sh
self-chat.py
server_startup_commands.md
setup.sh
sso-debugging.md
ufw_rules.txt
vite.config.js
</directory_structure>

<files>
This section contains the contents of the repository's files.

<file path="src/components/ImageLightbox.jsx">
import { useEffect, useRef, useState, useCallback } from 'react'

export default function ImageLightbox({ src, onClose }) {
  const [zoom, setZoom] = useState(1)
  const [panX, setPanX] = useState(0)
  const [panY, setPanY] = useState(0)
  const dragging = useRef(false)
  const startX = useRef(0)
  const startY = useRef(0)
  const pinchDist = useRef(0)

  function handleClose() {
    onClose()
    if (window.history.state && window.history.state.lightboxOpen) {
      window.history.back()
    }
  }

  useEffect(() => {
    if (!src) return
    const onPopState = () => onClose()
    window.addEventListener('popstate', onPopState)
    if (!(window.history.state && window.history.state.lightboxOpen)) {
      window.history.pushState({ lightboxOpen: true }, '')
    }
    return () => window.removeEventListener('popstate', onPopState)
  }, [src, onClose])

  const updateTransform = useCallback(() => {
    const img = document.getElementById('fullscreen-img')
    if (img) {
      img.style.transform = `scale(${zoom}) translate(${panX}px, ${panY}px)`
    }
    const label = document.getElementById('zoom-label')
    if (label) label.textContent = Math.round(zoom * 100) + '%'
  }, [zoom, panX, panY])

  useEffect(() => {
    if (!src) return
    setZoom(1)
    setPanX(0)
    setPanY(0)
    const img = document.getElementById('fullscreen-img')
    if (img) {
      img.src = src
      img.style.transform = ''
    }
    const label = document.getElementById('zoom-label')
    if (label) label.textContent = '100%'
  }, [src])

  useEffect(() => {
    if (!src) return
    const overlay = document.getElementById('image-overlay')

    function handleWheel(e) {
      e.preventDefault()
      const delta = e.deltaY > 0 ? -0.1 : 0.1
      setZoom(z => Math.max(0.25, Math.min(10, z + delta)))
    }

    function handleMouseDown(e) {
      const img = document.getElementById('fullscreen-img')
      if (e.target === img) {
        dragging.current = true
        startX.current = e.clientX - panX
        startY.current = e.clientY - panY
        overlay.style.cursor = 'grabbing'
        e.preventDefault()
      } else {
        handleClose()
      }
    }

    function handleMouseMove(e) {
      if (!dragging.current) return
      setPanX(e.clientX - startX.current)
      setPanY(e.clientY - startY.current)
    }

    function handleMouseUp() {
      if (dragging.current) {
        dragging.current = false
        overlay.style.cursor = 'grab'
      }
    }

    function handleTouchStart(e) {
      if (e.touches.length === 2) {
        const dx = e.touches[0].clientX - e.touches[1].clientX
        const dy = e.touches[0].clientY - e.touches[1].clientY
        pinchDist.current = Math.sqrt(dx * dx + dy * dy)
        dragging.current = false
        e.preventDefault()
      } else if (e.touches.length === 1 && e.target === document.getElementById('fullscreen-img')) {
        dragging.current = true
        startX.current = e.touches[0].clientX - panX
        startY.current = e.touches[0].clientY - panY
        e.preventDefault()
      }
    }

    function handleTouchMove(e) {
      if (e.touches.length === 2) {
        const dx = e.touches[0].clientX - e.touches[1].clientX
        const dy = e.touches[0].clientY - e.touches[1].clientY
        const dist = Math.sqrt(dx * dx + dy * dy)
        if (pinchDist.current > 0) {
          setZoom(z => Math.max(0.25, Math.min(10, z * (dist / pinchDist.current))))
          pinchDist.current = dist
        }
        e.preventDefault()
      } else if (e.touches.length === 1 && dragging.current) {
        setPanX(e.touches[0].clientX - startX.current)
        setPanY(e.touches[0].clientY - startY.current)
        e.preventDefault()
      }
    }

    function handleTouchEnd(e) {
      if (dragging.current && e.touches.length === 0) {
        dragging.current = false
      }
      if (e.changedTouches.length === 1 && e.target !== document.getElementById('fullscreen-img')) {
        handleClose()
      }
    }

    function handleKeyDown(e) {
      if (e.key === 'Escape') handleClose()
    }

    overlay.addEventListener('wheel', handleWheel, { passive: false })
    overlay.addEventListener('mousedown', handleMouseDown)
    document.addEventListener('mousemove', handleMouseMove)
    document.addEventListener('mouseup', handleMouseUp)
    overlay.addEventListener('touchstart', handleTouchStart, { passive: false })
    overlay.addEventListener('touchmove', handleTouchMove, { passive: false })
    overlay.addEventListener('touchend', handleTouchEnd)
    document.addEventListener('keydown', handleKeyDown)

    return () => {
      overlay.removeEventListener('wheel', handleWheel)
      overlay.removeEventListener('mousedown', handleMouseDown)
      document.removeEventListener('mousemove', handleMouseMove)
      document.removeEventListener('mouseup', handleMouseUp)
      overlay.removeEventListener('touchstart', handleTouchStart)
      overlay.removeEventListener('touchmove', handleTouchMove)
      overlay.removeEventListener('touchend', handleTouchEnd)
      document.removeEventListener('keydown', handleKeyDown)
    }
  }, [src, onClose, panX, panY])

  useEffect(() => {
    updateTransform()
  }, [zoom, panX, panY, updateTransform])

  if (!src) return null

  return (
    <div id="image-overlay" className="open">
      <div className="img-wrap"><img id="fullscreen-img" /></div>
      <div className="zoom-label" id="zoom-label">100%</div>
    </div>
  )
}
</file>

<file path="src/components/LocationPrompt.jsx">
export default function LocationPrompt({ onAllow, onDeny, error }) {
  return (
    <div id="location-overlay" onClick={onDeny}>
      <div id="location-dialog" onClick={e => e.stopPropagation()}>
        <div id="location-icon">📍</div>
        <div id="location-title">Share your location?</div>
        <div id="location-desc">
          {error || 'Local AI can use your location to provide location-aware responses and search results. You can change this anytime in your browser settings.'}
        </div>
        <div id="location-actions">
          <button id="location-deny-btn" onClick={onDeny}>Deny</button>
          <button id="location-allow-btn" onClick={onAllow} disabled={error && error.includes('blocked')}>Allow</button>
        </div>
      </div>
    </div>
  )
}
</file>

<file path="src/components/ModelBar.jsx">
import { useState, useEffect, useRef } from 'react'

export default function ModelBar({ modelStatus, modelTps, tokenEstimate, contextCompressed, rawTokenEstimate, maxContext, onToggleSidebar, username, onLogout, reminderCount, onToggleTasks }) {
  const [dropdownOpen, setDropdownOpen] = useState(false)
  const dropdownRef = useRef(null)

  useEffect(() => {
    function handleClick(e) {
      if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
        setDropdownOpen(false)
      }
    }
    document.addEventListener('click', handleClick)
    return () => document.removeEventListener('click', handleClick)
  }, [])

  const labels = {
    chat_loaded: 'Chat model ready',
    image_active: 'Image generation active',
    loading: 'Loading model...',
    unloading: 'Unloading model...',
    unloaded: 'No model loaded',
  }

  return (
    <div id="model-bar">
      <button id="sidebar-toggle" onClick={onToggleSidebar}>&#9776;</button>
      <span id="model-dot" className={modelStatus}></span>
      <span id="model-label">{labels[modelStatus] || modelStatus}</span>
      {modelTps != null && (
        <span id="model-tps" style={{ marginLeft: 12, fontSize: 12, color: '#888' }}>
          {modelTps < 5 ? (
            <span style={{ color: '#f87171' }}>&#9888; {modelTps.toFixed(1)} t/s</span>
          ) : (
            <>{modelTps.toFixed(1)} t/s</>
          )}
        </span>
      )}
      <span id="token-indicator">
        <svg id="context-donut" viewBox="0 0 24 24" width="18" height="18">
          <circle cx="12" cy="12" r="8" fill="none" stroke="rgba(255,255,255,0.1)" strokeWidth="3" />
          <circle
            cx="12" cy="12" r="8" fill="none"
            stroke={(tokenEstimate / maxContext) * 100 > 80 ? '#f87171' : (tokenEstimate / maxContext) * 100 > 60 ? '#fbbf24' : '#4ade80'}
            strokeWidth="3" strokeLinecap="round"
            strokeDasharray={Math.PI * 16}
            strokeDashoffset={Math.PI * 16 * (1 - Math.min((tokenEstimate / maxContext) * 100, 100) / 100)}
            transform="rotate(-90 12 12)"
          />
        </svg>
        {tokenEstimate > 0 && (
          <span className="token-text">
            {tokenEstimate > 1000 ? (tokenEstimate / 1000).toFixed(1) + 'k' : tokenEstimate} / {maxContext > 1000 ? (maxContext / 1000).toFixed(0) + 'k' : maxContext}
            {contextCompressed && rawTokenEstimate > 0 && (
              <span className="token-compressed" title="Context compressed — older messages summarized">
                ({rawTokenEstimate > 1000 ? (rawTokenEstimate / 1000).toFixed(1) + 'k' : rawTokenEstimate})
              </span>
            )}
          </span>
        )}
      </span>
      <div id="user-menu" ref={dropdownRef}>
        <span id="user-name" onClick={() => setDropdownOpen(o => !o)}>{username}</span>
        <div id="user-dropdown" className={dropdownOpen ? 'open' : ''}>
          <button className="task-menu-item" onClick={() => { onToggleTasks(); setDropdownOpen(false) }} title="Tasks">
            <span>&#9776; Tasks</span>
            {reminderCount > 0 && <span id="reminder-badge">{reminderCount}</span>}
          </button>
          <button onClick={onLogout}>Logout</button>
        </div>
      </div>
    </div>
  )
}
</file>

<file path="src/components/Sidebar.jsx">
export default function Sidebar({
  sessions,
  currentSessionId,
  onSwitchSession,
  onNewChat,
  onRenameSession,
  onDeleteSession,
  onClose,
  open,
}) {
  return (
    <>
      <div id="sidebar-overlay" className={open ? 'open' : ''} onClick={onClose}></div>
      <div id="sidebar" className={open ? 'open' : ''}>
        <div id="sidebar-header">
          <button id="new-chat-btn" onClick={onNewChat}>+ New Chat</button>
        </div>
        <div id="session-list">
          {sessions.map(s => (
            <div
              key={s.session_id}
              className={`session-item${s.session_id === currentSessionId ? ' active' : ''}`}
              onClick={() => onSwitchSession(s.session_id)}
            >
              <span className="session-name">
                {s.name.length > 30 ? s.name.slice(0, 30) + '...' : s.name}
              </span>
              <span className="session-actions">
                <button
                  title="Rename"
                  onClick={e => { e.stopPropagation(); onRenameSession(s.session_id) }}
                  dangerouslySetInnerHTML={{ __html: '&#9998;' }}
                />
                <button
                  title="Delete"
                  onClick={e => { e.stopPropagation(); onDeleteSession(s.session_id) }}
                  dangerouslySetInnerHTML={{ __html: '&#128465;' }}
                />
              </span>
            </div>
          ))}
        </div>
      </div>
    </>
  )
}
</file>

<file path="src/components/StatusBox.jsx">
function statusState(msg) {
  if (!msg) return 'thinking'
  if (/^Searching/.test(msg)) return 'search'
  if (/^Generating image/.test(msg)) return 'generate-image'
  if (/^Editing image/.test(msg)) return 'edit-image'
  return 'thinking'
}

const icons = {
  search: '\uD83D\uDD0D',
  'generate-image': '\uD83C\uDFA8',
  'edit-image': '\u270F\uFE0F',
  thinking: '\uD83D\uDCAD',
}

export default function StatusBox({ message }) {
  const state = statusState(message)
  return (
    <div className="status-box open" data-state={state}>
      <div className="status-header">
        <div className="left-group">
          <span className="status-icon">{icons[state]}</span>
          <span className="status-text">{message || 'Thinking...'}</span>
          <div className="spinner"></div>
        </div>
      </div>
    </div>
  )
}
</file>

<file path="src/components/TaskPanel.jsx">
import { useState, useEffect } from 'react'
import * as api from '../api'

export default function TaskPanel({ onClose }) {
  const [tasks, setTasks] = useState([])
  const [newTitle, setNewTitle] = useState('')
  const [newPriority, setNewPriority] = useState('medium')
  const [showForm, setShowForm] = useState(false)

  useEffect(() => { loadTasks() }, [])

  async function loadTasks() {
    const data = await api.fetchTasks()
    setTasks(data.tasks || [])
  }

  async function handleCreate(e) {
    e.preventDefault()
    if (!newTitle.trim()) return
    await api.createTask({ title: newTitle.trim(), priority: newPriority })
    setNewTitle('')
    setNewPriority('medium')
    setShowForm(false)
    loadTasks()
  }

  async function handleToggle(task) {
    if (task.status === 'completed') {
      await api.updateTask(task.id, { status: 'pending' })
    } else {
      await api.updateTask(task.id, { status: 'completed' })
    }
    loadTasks()
  }

  async function handleDelete(tid) {
    await api.deleteTask(tid)
    loadTasks()
  }

  const priorityColors = { high: '#f87171', medium: '#fbbf24', low: '#4ade80' }

  return (
    <div id="task-panel">
      <div id="task-panel-header">
        <span>Tasks</span>
        <button onClick={() => setShowForm(!showForm)}>{showForm ? 'x' : '+'}</button>
        <button onClick={onClose} id="task-panel-close">&#10005;</button>
      </div>
      {showForm && (
        <form id="task-form" onSubmit={handleCreate}>
          <input value={newTitle} onChange={e => setNewTitle(e.target.value)} placeholder="New task..." />
          <select value={newPriority} onChange={e => setNewPriority(e.target.value)}>
            <option value="low">Low</option>
            <option value="medium">Medium</option>
            <option value="high">High</option>
          </select>
          <button type="submit">Add</button>
        </form>
      )}
      <div id="task-list">
        {tasks.map(t => (
          <div key={t.id} className={`task-item ${t.status === 'completed' ? 'done' : ''}`}>
            <input type="checkbox" checked={t.status === 'completed'} onChange={() => handleToggle(t)} />
            <span className="task-title" style={{ color: priorityColors[t.priority] || '#94a3b8' }}>{t.title}</span>
            <span className="task-status">{t.status}</span>
            {t.due_date && <span className="task-due">{new Date(t.due_date).toLocaleDateString()}</span>}
            <button className="task-delete" onClick={() => handleDelete(t.id)}>&#128465;</button>
          </div>
        ))}
      </div>
    </div>
  )
}
</file>

<file path="src/main.jsx">
import { createRoot } from 'react-dom/client'
import App from './App'
import 'katex/dist/katex.min.css'
import './App.css'

createRoot(document.getElementById('root')).render(<App />)
</file>

<file path="index.html">
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
  <title>Local AI</title>
</head>
<body>
  <div id="root"></div>
  <script type="module" src="/src/main.jsx"></script>
</body>
</html>
</file>

<file path="LICENSE">
MIT License

Copyright (c) 2026 Palash90

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
</file>

<file path="requirements.txt">
requests
fastapi
uvicorn
PyJWT
Markdown
</file>

<file path="server_startup_commands.md">
# Draft on setup of low vram local ai setup

## Requirements

1. 2 to 4 concurrent users at max
2. Mostly 15 to 20 image generation per week
3. Multi-Lingual
4. Web Search
5. Recipe
6. Travel planning
7. Image analysis
8. Minor coding assistance
9. Usable within network
10. No dependency and data sharing with Big Tech

## Challenge

NVIDIA RTX 3050 Laptop GPU 4 GB VRAM, 16 GB RAM
No extra device to spare, same dev machine is used to host local AI

## Setup

### git

git clone <ComfyUI>
git clone <llama.cpp>

cd ComfyUI
python -m venv venv
pip install -r requirements.txt

cd llama.cpp
cmake # website gives info

### nvidia

Install nvidia cuda toolkit
Install nvidia container toolkit (Only if you want to run your ai server on a docker container, otherwise not needed)

### Docker

Install searxng - read setup.sh

In a docker container, install ubuntu:24.04 with gpus support

```shell
docker run -it --gpus all --name ai-container ubuntu:24.04
nvidia-smi # To confirm the nvidia support
nvcc --version # To verify nvcc
```

There is a separate docker-compose.yaml file in the repo if you want a containerized setup.

Internet Toggle for container:

```shell
docker network connect local-ai_external-net ai-container

docker exec -it ai-container bash

apt update && apt install -y tzdata
dpkg-reconfigure -f noninteractive tzdata

# Update packages and install useful utilities
apt update && apt install -y curl wget git python3 python3-pip nano pipx
apt-get update && apt-get install -y nvidia-cuda-toolkit

# Check GPU availability inside the container
nvidia-smi

docker network disconnect local-ai_external-net ai-container
```

### ComfyUI

Build step was there - read setup.sh

```shell
mkdir ~/local-ai
cd ~/local-ai
source venv/bin/activate
python main.py --lowvram --input-directory ~/local-ai-files/ComfyUI/input --output-directory ~/local-ai-files/ComfyUI/output
```

### Llama Server

Build step was there - read seup.sh

```shell
 ~/local-ai/llama.cpp/build/bin/llama-server --host 0.0.0.0 --port 8081 --models-dir ~/local-ai-files/my-models/ --n-gpu-layers 99 --no-kv-offload --ctx-size 16384 --reasoning-budget 2048

```

### chat-server

```shell
cd ~/git/local-ai
python chat-webui.py    
```

# Configuration Files

List of files - read setup.sh

# Reverse Proxy & HTTPS (To be added)

## 1. Install Nginx and clean default site

```shell
sudo apt update && sudo apt install nginx -y
sudo rm -f /etc/nginx/sites-enabled/default
```

## 2. Create Nginx config

```shell
sudo tee /etc/nginx/sites-available/chat.local > /dev/null << 'EOF'
server {
    listen 80;
    server_name chat.local;

    location / {
        proxy_pass http://127.0.0.1:3001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
EOF
```

## 3. Enable site and test Nginx

```shell
sudo ln -sf /etc/nginx/sites-available/chat.local /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl restart nginx
```

## mDNS Setup

### 1. Set hostname (Avahi will automatically broadcast chat.local)

```shell
sudo hostnamectl set-hostname chat
```

### 2. Create Avahi mDNS HTTP Service discovery (pointing to Nginx on Port 80)

```shell
sudo tee /etc/avahi/services/chat.service > /dev/null << 'EOF'
<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
  <name>Chat AI</name>
  <service>
    <type>_http._tcp</type>
    <port>80</port>
  </service>
</service-group>
EOF
```

### 3. Restart Avahi to reload the service definition

```shell
sudo systemctl restart avahi-daemon
```
</file>

<file path="ufw_rules.txt">
sudo ufw allow in on wlan0
sudo ufw allow out on wlan0
</file>

<file path="vite.config.js">
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  base: './',
  build: {
    outDir: 'dist',
  },
  server: {
    proxy: {
      '/api': 'http://localhost:3000',
      '/output': 'http://localhost:3000',
    },
  },
})
</file>

<file path="prompts/critique.txt">
You are the story verification pass for the Kaya–Kolpo pipeline. The draft to
review is NOT yours — your partner wrote it. Your job is exactly like a research
fact-checker: verify the draft against the rules below, and where a rule is
violated, fix THAT spot and only that spot. You never rewrite the story, never
add new scenes, ideas, dialogue, images, or sections, and never fix things that
are not flagged.

## Task context
- Genre: %genre%
- Declared mediums: %mediums%
- Declared language(s): %language%
- Task details: %details%

## Named characters (immutable)
%cast%

The characters above were decided and named before writing. This is the COMPLETE
cast: the story text and every image may only depict these named characters.
Never introduce, name, or depict any additional character in your fix.

## Genre-specific checklist
Check the flagged parts against every item below. Fix what you can directly in
the spot where it occurs. If a violation genuinely cannot be fixed with the
material available (for example an image was never generated), do NOT invent a
fix — leave that spot untouched and report it in the response.

%checklist%

## Non-negotiable preservation rules
- Never remove or alter the header metadata block (Task prompt, Genre, For roles,
  Mediums, Language(s)).
- Never remove or move existing image references (`![...](...)`): each image
  stays exactly where it appears, right after the paragraph it illustrates.
- Never change the heading "## Citations & References" and never remove or shorten
  the citations that the story already contains.
- Never write the names "Kaya", "Kolpo", "কায়া", "কল্প", "काया", "कल्प" anywhere
  in the content text, dialogue, captions, or headings.
- Keep the content in the declared language(s). Do not translate.
- Strip any meta-commentary, planning chatter, or conversational setup. Retain
  only the final, continuous story narrative and structural headers.

## WORK MODE
- First, quote the exact spot of every flagged violation (paste the offending
  line or paragraph).
- Then fix ONLY those spots. Everything outside the flagged spots must remain
  byte-for-byte identical.
- Your reply must be TWO parts, in this order:
  1. A short "<!-- CRITIQUE: <verdict for each flagged item: FIXED / UNRESOLVED> -->"
     HTML comment on its own line.
  2. The COMPLETE corrected markdown of the story in a single ```markdown code
     block. Nothing else outside the comment and the code block.
- If everything was resolved, every verdict is FIXED. Never skip the code block;
  the full markdown must always be returned.
</file>

<file path="scripts/authentik_bootstrap.py">
#!/usr/bin/env python3
"""Provision Authentik for the unified SSO: groups, users, the OIDC provider
and the proxy outpost.

Run this once after ``docker compose -f authentik-compose.yaml up -d`` and the
initial-setup flow have created the admin account:

    python3 scripts/authentik_bootstrap.py

It needs admin credentials. Provide them inline via flags or via the .env
values (AUTHENTIK_BOOTSTRAP_EMAIL / AUTHENTIK_BOOTSTRAP_PASSWORD), or pass a
service-account token with ``--token`` (AUTHENTIK_BOOTSTRAP_TOKEN/.env
AUTHENTIK_TOKEN). On success it prints the outpost token you must put into
the image deployment (append --token to an already-scaled outpost, or deploy
as the ``ghcr.io/goauthentik/proxy`` sidecar with OUTPOST_TOKEN=...).

Uses only the Python stdlib + requests (already a dependency).
"""

import argparse
import json
import sys
import uuid

import requests

from server.dotenv import load_dotenv

load_dotenv()

ADMIN_USERS = {"palash"}
PREMIUM_USERS = {"totan"}
FREE_USERS = {"kolpo", "kaya", "editor", "moderator", "test"}

DEFAULT_PASSWORD = "changeme!authentik1"
DEFAULT_EMAIL = "auth@localhost"


def api(base, token, method, path, **kwargs):
    url = f"{base}/api/v3/{path}"
    headers = {"Authorization": f"Bearer {token}"} if token else {}
    if method == "POST":
        headers["Content-Type"] = "application/json"
    resp = requests.request(method, url, headers=headers, timeout=30, **kwargs)
    if resp.status_code >= 400 and method != "GET":
        print(f"[api] {method} {path} -> {resp.status_code}: {resp.text[:300]}")
    return resp


def main():
    parser = argparse.ArgumentParser(description=__doc__.strip())
    parser.add_argument("--base", default="", help="Authentik base URL (default: AUTHENTIK_BASE_URL from .env)")
    parser.add_argument("--email", default="", help="Admin email (default: AUTHENTIK_BOOTSTRAP_EMAIL)")
    parser.add_argument("--password", default="", help="Admin password (default: AUTHENTIK_BOOTSTRAP_PASSWORD)")
    parser.add_argument("--token", default="", help="Authentik API token (skips password auth if set)")
    args = parser.parse_args()

    import os

    base = (args.base or os.environ.get("AUTHENTIK_BASE_URL") or "https://home.palashkantikundu.in/sso").rstrip("/")
    email = args.email or os.environ.get("AUTHENTIK_BOOTSTRAP_EMAIL") or DEFAULT_EMAIL
    password = args.password or os.environ.get("AUTHENTIK_BOOTSTRAP_PASSWORD") or DEFAULT_PASSWORD
    token = args.token or os.environ.get("AUTHENTIK_BOOTSTRAP_TOKEN") or os.environ.get("AUTHENTIK_TOKEN") or ""

    if not token:
        # Username is the local part of the bootstrap email.
        username = email.split("@")[0] or "akadmin"
        resp = api(base, "", "POST", "core/users/me/impersonation/")
        # Password-based token is simpler: use the admin/user token endpoint.
        tr = requests.post(
            f"{base}/api/v3/core/tokens/",
            json={
                "identifier": f"bootstrap-{uuid.uuid4().hex[:8]}",
                "intent": "app_password",
                "user": 1,
                "expiring": False,
            },
            headers={"Content-Type": "application/json"},
            timeout=15,
        )
        if tr.status_code == 401:
            print("No --token and no usable AUTHENTIK_BOOTSTRAP_TOKEN; refusing.")
            print(f"Either set the token in .env or sign in to {base}/if/flow/initial-setup/ first.")
            sys.exit(1)
        if tr.status_code != 201:
            print(f"[tokens] bootstrap token creation failed: {tr.status_code} {tr.text[:300]}")
            sys.exit(1)
        token = tr.json()["key"]

    print(f"Using Authentik at {base} with admin token.")

    groups = {}
    for gname in ("admin", "premium", "free"):
        r = api(base, token, "GET", f"core/groups/?name={gname}")
        if r.status_code == 200 and r.json().get("results"):
            groups[gname] = r.json()["results"][0]["pk"]
        else:
            r = api(base, token, "POST", "core/groups/", json={"name": gname})
            # 400 name-taken races are fine; re-list after.
            if r.status_code >= 400:
                r = api(base, token, "GET", f"core/groups/?name={gname}")
                if r.status_code == 200 and r.json().get("results"):
                    groups[gname] = r.json()["results"][0]["pk"]
                continue
            groups[gname] = r.json()["pk"]
        print(f"  group {gname}: {groups[gname]}")

    def ensure_user(uid, role_group):
        r = api(base, token, "GET", f"core/users/?username={uid}")
        if r.status_code == 200 and r.json().get("results"):
            user = r.json()["results"][0]
            api(base, token, "POST", f"core/users/{user['pk']}/", json={"password": DEFAULT_PASSWORD})
            api(base, token, "POST", f"core/users/{user['pk']}/groups/", json={"pk": groups[role_group]})
        else:
            r = api(base, token, "POST", "core/users/", json={
                "username": uid,
                "name": uid,
                "email": f"{uid}@localhost",
                "password": DEFAULT_PASSWORD,
                "is_active": True,
            })
            if r.status_code >= 400:
                print(f"[user] {uid}: {r.status_code} {r.text[:200]}")
                return
            user = r.json()
            api(base, token, "POST", f"core/users/{user['pk']}/groups/", json={"pk": groups[role_group]})
            print(f"  user {uid} (role {role_group})")

    for uid in ADMIN_USERS:
        ensure_user(uid, "admin")
    for uid in PREMIUM_USERS:
        ensure_user(uid, "premium")
    for uid in FREE_USERS:
        ensure_user(uid, "free")

    # Proxy outpost: nginx auth_request hits it at /outpost.goauthentik.io.
    r = api(base, token, "GET", "core/outposts/?name=nginx-ssd")
    if r.status_code == 200 and r.json().get("results"):
        outpost = r.json()["results"][0]
    else:
        r = api(base, token, "POST", "core/outposts/", json={
            "name": "nginx-ssd",
            "type": "proxy",
        })
        outpost = r.json()
    outpost_pk = outpost["pk"]
    print(f"  outpost nginx-ssd: {outpost_pk}")

    # OIDC provider "local-ai" → agent password grant + JWT issuance.
    prov = None
    r = api(base, token, "GET", "core/providers/oauth2/?name=local-ai")
    if r.status_code == 200 and r.json().get("results"):
        prov = r.json()["results"][0]
        provider_pk = prov["pk"]
    else:
        r = api(base, token, "POST", "core/providers/oauth2/", json={
            "name": "local-ai",
            "authorization_flow": _first_flow(base, token, "authorization"),
            "client_type": "confidential",
            "client_id": os.environ.get("AUTH_CLIENT_ID", "local-ai"),
            "client_secret": os.environ.get("AUTH_CLIENT_SECRET") or uuid.uuid4().hex,
            "signing_key": _first_signing_key(base, token),
            "access_code_validity": "minutes=10",
            "access_token_validity": "minutes=10",
            "refresh_token_validity": "days=30",
            "include_claims_in_id_token": True,
            "issuer_mode": "global",
            "sub_mode": "hashed_user_id",
            # Password grant support (self-chat agents).
            "redirect_uris": [],
            "property_mappings": [],
        })
        if r.status_code >= 400:
            print(f"[provider] {r.status_code} {r.text[:300]}")
            sys.exit(1)
        prov = r.json()
        provider_pk = prov["pk"]

    print(f"  oauth2 provider local-ai: {provider_pk}")
    print("\nProvisioning complete.")
    print("\nThe proxy outpost token (OUTPOST token) is shown in the Authentik")
    print("admin UI → Outposts → nginx-ssd → Details. Deploy the outpost as:")
    print("  docker run -d --name authentik-proxy --network host \\")
    print("    -e AUTHENTIK_HOST=https://home.palashkantikundu.in/sso \\")
    print("    -e AUTHENTIK_TOKEN=<outpost token> \\")
    print("    ghcr.io/goauthentik/proxy:2025.2.1")
    print("\nThen in the admin UI: Applications → local-ai → add the proxy")
    print("provider. nginx auth_request already points at the outpost.")


def _first_flow(base, token, slug):
    r = api(base, token, "GET", f"flows/instances/?slug={slug}")
    items = r.json().get("results", []) if r.status_code == 200 else []
    return items[0]["pk"] if items else None


def _first_signing_key(base, token):
    r = api(base, token, "GET", "crypto/certificatekeypairs/")
    items = r.json().get("results", []) if r.status_code == 200 else []
    # Prefer a key usable for JWT signing (private key present).
    for it in items:
        if it.get("private_key"):
            return it["pk"]
    return items[0]["pk"] if items else None


if __name__ == "__main__":
    main()
</file>

<file path="scripts/gcp-heartbeat.service">
# /etc/systemd/system/gcp-heartbeat.service

[Service]
Type=simple
User=heartbeat
Group=heartbeat
WorkingDirectory=/opt/heartbeat
ExecStart=/usr/bin/python3 /opt/heartbeat/gcp_heartbeat_server.py --dns-port 0 --log /opt/heartbeat/logs/heartbeat.log
Restart=always
RestartSec=3
StartLimitIntervalSec=60
StartLimitBurst=5

StandardOutput=journal
StandardError=journal

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/heartbeat/logs

[Install]
WantedBy=multi-user.target
</file>

<file path="server/features/__init__.py">
"""Feature modules for the chat web UI.

The chat engine used to be a single ``chat-webui.py`` file. The implementation
now lives in this package, split by feature area:

* :mod:`~server.features.state` — shared state, the ``M`` entrypoint proxy
* :mod:`~server.features.tasks_db` — SQLite-backed to-do tasks
* :mod:`~server.features.users` — login, user context, usernames
* :mod:`~server.features.sessions` — conversation persistence
* :mod:`~server.features.context` — token estimation / context compaction
* :mod:`~server.features.llm` — llama-server lifecycle and streaming
* :mod:`~server.features.tools` — LLM tool implementations
* :mod:`~server.features.images` — ComfyUI image generation / editing
* :mod:`~server.features.monitoring` — health loops, restart, thermal/RAM
* :mod:`~server.features.orchestration` — event loop and task queue

``chat-webui.py`` remains the single entrypoint: it owns every shared value and
registers itself as the entrypoint module. Feature code resolves shared state,
config values and cross-cutting helpers at call time through the ``M`` proxy
(see :mod:`server.features.state`), so monkeypatching ``chat-webui.<name>`` —
as the test-suite does — keeps working across module boundaries.
"""

from server.features import (
    context,
    images,
    llm,
    monitoring,
    orchestration,
    sessions,
    state,
    tasks_db,
    tools,
    users,
)

__all__ = [
    "context",
    "images",
    "llm",
    "monitoring",
    "orchestration",
    "sessions",
    "state",
    "tasks_db",
    "tools",
    "users",
]
</file>

<file path="server/features/shares.py">
"""Public message sharing: create, fetch and revoke single-message shares.

A share is a capability URL: whoever knows the unguessable token can read the
message without logging in. The message content is snapshotted at share time so
later edits to or deletion of the session never change or break the share.

Storage is a simple JSON file (``SHARES_FILE``) shaped as::

    {
        "<token>": {
            "session_id": "...",
            "msg_index": 3,
            "owner": "alice",
            "created": 1234567890.0,
            "message": { "role": "assistant", "content": "...", ... }
        }
    }
"""

import copy
import json
import os
import time
import uuid

from server.features.state import M


def load_shares():
    """Load share records from disk into the shared ``shares`` container."""
    with M._data_lock:
        M.shares.clear()
    path = M.SHARES_FILE
    if not path or not os.path.exists(path):
        return
    try:
        with open(path, encoding="utf-8") as f:
            data = json.load(f)
    except (OSError, json.JSONDecodeError):
        return
    with M._data_lock:
        for token, rec in (data or {}).items():
            M.shares[token] = rec


def save_shares():
    """Persist the in-memory ``shares`` container to disk."""
    with M._data_lock:
        data = copy.deepcopy(dict(M.shares))
    path = M.SHARES_FILE
    if not path:
        return
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)


# Message fields worth carrying into a public snapshot. Everything else is
# internal bookkeeping that the public page must never see.
_SNAPSHOT_KEYS = (
    "role",
    "content",
    "_image_url",
    "_image_model",
    "_gen_prompt",
    "_reasoning",
    "_tools_used",
    "_search_details",
    "_elapsed_ms",
    "_timestamp",
)


def _snapshot_message(msg):
    snap = {}
    for key in _SNAPSHOT_KEYS:
        if key in msg:
            snap[key] = copy.deepcopy(msg[key])
    return snap


def _share_url(token):
    """Public URL for a share.

    Uses ``SHARE_BASE_URL`` (a portless origin, see config) when set, otherwise
    falls back to a site-relative path. A ported origin like ``:3001`` is
    deliberately avoided because WhatsApp stops auto-linking URLs at the ":" of
    a port, which truncates every share link to a dead short URL.
    """
    base = M.SHARE_BASE_URL
    return f"{base}/s/{token}" if base else f"/s/{token}"


def create_share(user, session_id, msg_index):
    """Create a share for a single assistant message.

    Validates session ownership and that the target message is an assistant
    (bot) message, then snapshots it. Returns ``(token, url)``.

    Raises ``ValueError`` with a user-facing message on any validation failure.
    """
    try:
        msg_index = int(msg_index)
    except (TypeError, ValueError):
        raise ValueError("Invalid message index")

    with M._data_lock:
        meta = M.sessions_meta.get(session_id)
        if not meta:
            raise ValueError("Session not found")
        if meta.get("user_id", "") != user:
            raise ValueError("Not your session")
        msgs = M.sessions.get(session_id)
        if not msgs or msg_index < 0 or msg_index >= len(msgs):
            raise ValueError("Message not found")
        msg = msgs[msg_index]
        if msg.get("role") != "assistant":
            raise ValueError("Only assistant messages can be shared")

    token = uuid.uuid4().hex
    with M._data_lock:
        M.shares[token] = {
            "session_id": session_id,
            "msg_index": msg_index,
            "owner": user,
            "created": time.time(),
            "message": _snapshot_message(msg),
        }
    save_shares()
    return token, _share_url(token)


def get_share(token):
    """Return the share record for a token, or ``None`` if missing/revoked."""
    if not token:
        return None
    with M._data_lock:
        return M.shares.get(token)


def revoke_share(token, user):
    """Remove a share. Only the owner may revoke.

    Returns ``True`` on success, ``False`` if the share does not exist or the
    user is not its owner.
    """
    with M._data_lock:
        rec = M.shares.get(token)
        if not rec or rec.get("owner") != user:
            return False
        del M.shares[token]
    save_shares()
    return True


def list_shares(user):
    """Return the caller's share records (with a plain-text preview)."""
    out = []
    with M._data_lock:
        for token, rec in M.shares.items():
            if rec.get("owner") != user:
                continue
            content = rec.get("message", {}).get("content", "")
            preview = ""
            if isinstance(content, str):
                preview = content[:120]
            elif isinstance(content, list):
                for part in content:
                    if isinstance(part, dict) and part.get("type") == "text":
                        preview = (part.get("text") or "")[:120]
                        break
            out.append(
                {
                    "token": token,
                    "url": _share_url(token),
                    "session_id": rec.get("session_id"),
                    "msg_index": rec.get("msg_index"),
                    "created": rec.get("created"),
                    "preview": preview,
                }
            )
    return sorted(out, key=lambda s: s.get("created") or 0, reverse=True)
</file>

<file path="server/features/tasks_db.py">
"""SQLite-backed to-do tasks used by the ``manage_tasks`` tool and /api/tasks."""

import json
import sqlite3
import threading
import uuid
from datetime import datetime

from server.features.state import M

_tasks_db_lock = threading.Lock()


def _init_tasks_db():
    with _tasks_db_lock:
        conn = sqlite3.connect(M.TASKS_DB)
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS tasks (
                id TEXT PRIMARY KEY,
                user_id TEXT NOT NULL,
                title TEXT NOT NULL,
                description TEXT DEFAULT '',
                status TEXT DEFAULT 'pending',
                priority TEXT DEFAULT 'medium',
                due_date TEXT,
                session_id TEXT,
                created_at TEXT NOT NULL,
                updated_at TEXT NOT NULL,
                reminder_at TEXT,
                reminded INTEGER DEFAULT 0
            )
        """
        )
        conn.commit()
        conn.close()


def _db_run(query, params=()):
    with _tasks_db_lock:
        conn = sqlite3.connect(M.TASKS_DB)
        try:
            cur = conn.execute(query, params)
            conn.commit()
            return cur.rowcount
        finally:
            conn.close()


def _db_fetch(query, params=()):
    with _tasks_db_lock:
        conn = sqlite3.connect(M.TASKS_DB)
        conn.row_factory = sqlite3.Row
        try:
            cur = conn.execute(query, params)
            rows = [dict(r) for r in cur.fetchall()]
            return rows
        finally:
            conn.close()


def _db_fetch_one(query, params=()):
    rows = M._db_fetch(query, params)
    return rows[0] if rows else None


def task_create(
    user_id,
    title,
    description="",
    priority="medium",
    due_date=None,
    session_id=None,
    reminder_at=None,
):
    tid = str(uuid.uuid4())
    now = datetime.now().isoformat()
    M._db_run(
        "INSERT INTO tasks (id, user_id, title, description, status, priority, due_date, session_id, created_at, updated_at, reminder_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
        (
            tid,
            user_id,
            title,
            description,
            "pending",
            priority,
            due_date,
            session_id,
            now,
            now,
            reminder_at,
        ),
    )
    return M._db_fetch_one("SELECT * FROM tasks WHERE id=?", (tid,))


def task_update(tid, user_id, **kwargs):
    fields = {k: v for k, v in kwargs.items() if v is not None}
    if not fields:
        return None
    fields["updated_at"] = datetime.now().isoformat()
    set_clause = ", ".join(f"{k}=?" for k in fields)
    vals = list(fields.values()) + [tid, user_id]
    M._db_run(f"UPDATE tasks SET {set_clause} WHERE id=? AND user_id=?", vals)
    return M._db_fetch_one("SELECT * FROM tasks WHERE id=?", (tid,))


def task_complete(tid, user_id):
    now = datetime.now().isoformat()
    M._db_run(
        "UPDATE tasks SET status='completed', updated_at=? WHERE id=? AND user_id=?",
        (now, tid, user_id),
    )
    return M._db_fetch_one("SELECT * FROM tasks WHERE id=?", (tid,))


def task_delete(tid, user_id):
    return M._db_run("DELETE FROM tasks WHERE id=? AND user_id=?", (tid, user_id))


def task_list(user_id, status=None):
    if status:
        return M._db_fetch(
            "SELECT * FROM tasks WHERE user_id=? AND status=? ORDER BY due_date IS NULL, due_date ASC, created_at DESC",
            (user_id, status),
        )
    return M._db_fetch(
        "SELECT * FROM tasks WHERE user_id=? ORDER BY due_date IS NULL, due_date ASC, created_at DESC",
        (user_id,),
    )


def task_get(tid, user_id):
    return M._db_fetch_one(
        "SELECT * FROM tasks WHERE id=? AND user_id=?", (tid, user_id)
    )


def handle_task_tool(user_id, args):
    op = args.get("operation", "")
    if op == "create":
        if not args.get("title"):
            return json.dumps({"ok": False, "error": "Missing required argument: title"})
        t = task_create(
            user_id,
            args["title"],
            args.get("description", ""),
            args.get("priority", "medium"),
            args.get("due_date"),
            args.get("session_id"),
            args.get("reminder_at"),
        )
        return json.dumps({"ok": True, "task": t})
    elif op in ("update", "complete", "delete", "get"):
        tid = args.get("task_id")
        if not tid:
            return json.dumps(
                {"ok": False, "error": f"Missing required argument: task_id"}
            )
        if op == "update":
            t = task_update(
                tid,
                user_id,
                title=args.get("title"),
                description=args.get("description"),
                priority=args.get("priority"),
                status=args.get("status"),
                due_date=args.get("due_date"),
                reminder_at=args.get("reminder_at"),
            )
            if t:
                return json.dumps({"ok": True, "task": t})
            return json.dumps({"ok": False, "error": "Task not found"})
        elif op == "complete":
            t = task_complete(tid, user_id)
            if t:
                return json.dumps({"ok": True, "task": t})
            return json.dumps({"ok": False, "error": "Task not found"})
        elif op == "delete":
            task_delete(tid, user_id)
            return json.dumps({"ok": True})
        else:
            t = task_get(tid, user_id)
            if t:
                return json.dumps({"ok": True, "task": t})
            return json.dumps({"ok": False, "error": "Task not found"})
    elif op == "list":
        tasks = task_list(user_id, args.get("status"))
        return json.dumps({"ok": True, "tasks": tasks})
    return json.dumps({"ok": False, "error": f"Unknown operation: {op}"})
</file>

<file path="server/dotenv.py">
"""Minimal ``.env`` loader (stdlib only — no external dependency).

Reads a ``KEY=VALUE`` file from the repo root into ``os.environ`` so every
entrypoint (``chat-webui.py``, ``self-chat.py``, ``markdown_hosting.py``)
sees the same settings whether it is launched by hand, systemd or cron.

Only values that are NOT already exported by the real environment are applied,
so a systemd ``EnvironmentFile`` or the shell always wins over the ``.env``.
"""

import os
from pathlib import Path


def load_dotenv(path=None):
    """Load ``path`` (default: repo-root ``.env``) into ``os.environ``."""
    if path is None:
        path = Path(__file__).resolve().parent.parent / ".env"
    path = Path(path)
    if not path.is_file():
        return False
    loaded = False
    for raw in path.read_text().splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, _, value = line.partition("=")
        key = key.strip()
        value = value.strip()
        if not key:
            continue
        if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
            value = value[1:-1]
        if key and key not in os.environ:
            os.environ[key] = value
            loaded = True
    return loaded


load_dotenv()
</file>

<file path="src/components/ChatArea.jsx">
import { useEffect, useRef } from 'react'
import Message from './Message'

export default function ChatArea({ messages, pendingMessages, currentSessionId, onImageOpen, selectingRef, onPendingResolved, onLocationNeeded }) {
  const bottomRef = useRef(null)
  const chatRef = useRef(null)
  const userScrolledUp = useRef(false)

  const currentPending = pendingMessages
    ? Object.values(pendingMessages).filter(p => p.sessionId === currentSessionId)
    : []

  const allMessages = [...messages, ...currentPending.map(p => ({ _pending: true, ...p }))]

  useEffect(() => {
    const el = chatRef.current
    if (!el) return
    const handler = () => {
      const threshold = 100
      userScrolledUp.current = el.scrollHeight - el.scrollTop - el.clientHeight > threshold
    }
    el.addEventListener('scroll', handler, { passive: true })
    return () => el.removeEventListener('scroll', handler)
  }, [])

  useEffect(() => {
    const el = chatRef.current
    if (!el || !selectingRef) return
    const down = () => { selectingRef.current = true }
    const up = () => { selectingRef.current = false }
    el.addEventListener('pointerdown', down)
    el.addEventListener('pointerup', up)
    el.addEventListener('pointercancel', up)
    el.addEventListener('touchend', up)
    window.addEventListener('mouseup', up)
    return () => {
      el.removeEventListener('pointerdown', down)
      el.removeEventListener('pointerup', up)
      el.removeEventListener('pointercancel', up)
      el.removeEventListener('touchend', up)
      window.removeEventListener('mouseup', up)
    }
  }, [selectingRef])

  useEffect(() => {
    if (!selectingRef) return
    const onSelChange = () => {
      let active = false
      try { active = !!document.getSelection() && !document.getSelection().isCollapsed } catch { }
      selectingRef.current = active
    }
    document.addEventListener('selectionchange', onSelChange)
    return () => document.removeEventListener('selectionchange', onSelChange)
  }, [selectingRef])

  useEffect(() => {
    if (bottomRef.current && !userScrolledUp.current) {
      bottomRef.current.scrollIntoView({ behavior: 'smooth' })
    }
  }, [messages.length, currentPending.length])

  return (
    <div id="chat" ref={chatRef}>
      {messages.map((msg, i) => (
        <Message
          key={i}
          msg={msg}
          sessionId={currentSessionId}
          msgIndex={i}
          onImageOpen={onImageOpen}
          selectingRef={selectingRef}
          onResolved={onPendingResolved}
          onLocationNeeded={onLocationNeeded}
        />
      ))}
      {currentPending.map((p, i) => (
        <Message
          key={'p-' + (p.taskId || i)}
          pending={p}
          onImageOpen={onImageOpen}
          selectingRef={selectingRef}
          onResolved={onPendingResolved}
          onLocationNeeded={onLocationNeeded}
        />
      ))}
      <div ref={bottomRef} />
    </div>
  )
}
</file>

<file path="src/components/OverloadWarning.jsx">
export default function OverloadWarning({ overheated, gpuTemp, ramEvacuating }) {
  if (!overheated && !ramEvacuating) return null
  const tempStr = gpuTemp != null ? gpuTemp + '\u00B0C' : ''
  const text = ramEvacuating
    ? 'Server overloaded (high memory). Your queued messages are paused while RAM is freed and servers restart.'
    : 'Server overloaded. Your queued messages will be processed once the GPU cools down.'
  return (
    <div id="overload-warn" style={{ display: 'block' }}>
      {text}{tempStr ? ' (GPU: ' + tempStr + ')' : ''}
    </div>
  )
}
</file>

<file path="src/components/PublicShareView.jsx">
import { useState, useEffect, useCallback } from 'react'
import { fetchPublicShare } from '../api'
import Message from './Message'
import ImageLightbox from './ImageLightbox'

export default function PublicShareView({ token, onExit }) {
  const [state, setState] = useState({ loading: true, error: '', message: null, sharedBy: '' })
  const [lightboxSrc, setLightboxSrc] = useState(null)

  useEffect(() => {
    let alive = true
    fetchPublicShare(token)
      .then(data => {
        if (!alive) return
        if (data && data.message) {
          setState({ loading: false, error: '', message: data.message, sharedBy: data.shared_by || '' })
        } else {
          setState({
            loading: false,
            error: (data && data.error) || 'This shared message is no longer available.',
            message: null,
            sharedBy: '',
          })
        }
      })
      .catch(() => {
        if (alive) setState({ loading: false, error: 'Could not load the shared message.', message: null, sharedBy: '' })
      })
    return () => { alive = false }
  }, [token])

  const openImage = useCallback(src => setLightboxSrc(src), [])
  const closeLightbox = useCallback(() => setLightboxSrc(null), [])

  return (
    <div id="public-share-view">
      <div className="public-share-topbar">
        <span className="public-share-label">Shared message</span>
        {onExit && (
          <button type="button" className="public-share-exit" onClick={onExit}>
            Log in to chat
          </button>
        )}
      </div>
      {state.loading ? (
        <div className="public-share-status">Loading…</div>
      ) : state.error ? (
        <div className="public-share-status error">{state.error}</div>
      ) : (
        <div className="public-share-body">
          <div className="public-share-meta">Shared by <strong>{state.sharedBy || 'someone'}</strong></div>
          <Message msg={state.message} onImageOpen={openImage} hideSpeak />
        </div>
      )}
      <ImageLightbox src={lightboxSrc} onClose={closeLightbox} />
    </div>
  )
}
</file>

<file path="src/utils.js">
export function toApiImage(url) {
  if (!url || typeof url !== 'string') return url
  if (url.startsWith('data:') || /^https?:/i.test(url) || url.startsWith('/api/image/')) return url
  if (url.startsWith('/uploads/') || url.startsWith('/output/')) return '/api/image/' + url.slice(1)
  if (url.startsWith('/')) return '/api/image/' + url.slice(1)
  return url
}

export async function downloadFile(url, fallbackName = 'file') {
  const full = url.startsWith('http') || url.startsWith('data:') ? url : window.location.origin + url
  const filename = full.split('/').pop() || fallbackName
  try {
    const res = await fetch(full)
    const blob = await res.blob()
    const a = document.createElement('a')
    a.href = URL.createObjectURL(blob)
    a.download = filename
    document.body.appendChild(a)
    a.click()
    document.body.removeChild(a)
    URL.revokeObjectURL(a.href)
  } catch {
    window.open(full, '_blank')
  }
}
</file>

<file path=".repomixignore">
prompts/master_details.json
</file>

<file path="gcp_nginx.conf">
server {
    listen 80;
    server_name home.palashkantikundu.in;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name home.palashkantikundu.in;

    ssl_certificate     /etc/nginx/ssl/home.palashkantikundu.in/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/home.palashkantikundu.in/privkey.pem;

    # Intercept errors from home server connection
    proxy_intercept_errors on;
    error_page 502 503 504 = @server_offline;

    # Inline HTML page served when main server is down
    location @server_offline {
        default_type text/html;
        return 502 '<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Main Server Offline</title>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0d1117; color: #c9d1d9; display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px; }
        .card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 32px; max-width: 420px; width: 100%; text-align: center; box-shadow: 0 8px 24px rgba(0,0,0,0.5); }
        .icon { font-size: 48px; margin-bottom: 16px; }
        .title { font-size: 20px; font-weight: 600; color: #f85149; margin-bottom: 8px; }
        .desc { font-size: 14px; color: #8b949e; line-height: 1.5; }
    </style>
</head>
<body>
    <div class="card">
        <div class="icon">⚠️</div>
        <div class="title">Main Server Offline</div>
        <div class="desc">The home server is currently unreachable or powered off. Please try again later.</div>
    </div>
</body>
</html>';
    }

    location / {
            proxy_pass https://10.66.66.3;
            proxy_ssl_server_name on;
            proxy_ssl_verify off;

            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Via-GCP "true";

            # Fast failover timeouts
            proxy_connect_timeout 3s;
            proxy_read_timeout 5s;
            proxy_send_timeout 5s;
    }
}
</file>

<file path="genre_creator.py">
import json
import os

FILE_NAME = "nested_schema_template.json"

def load_existing_schema():
    if os.path.exists(FILE_NAME):
        try:
            with open(FILE_NAME, "r", encoding="utf-8") as f:
                return json.load(f)
        except json.JSONDecodeError:
            pass
    return {
        "tasks_entry": [],
        "genre_persona_map_entry": {},
        "persona_pool_entry": {},
        "genre_checklists_entry": {}
    }

def generate_nested_schema():
    data = load_existing_schema()

    print("=== Interactive Deep-Merging Schema Builder ===")

    while True:
        genre = input("\nEnter Genre (e.g., Regional Fashion): ").strip()
        if not genre:
            break

        # 1. Append Task if not already present
        task_exists = any(t.get("genre") == genre for t in data["tasks_entry"])
        if not task_exists:
            data["tasks_entry"].append({
                "task": f"Sample Task for {genre}",
                "details": "Provide detailed task instructions here.",
                "genre": genre,
                "roles": ["free"],
                "languages": ["English", "bengali"],
                "mediums": ["text", "image"]
            })

        # 2. Init Genre-Persona Map & Checklist
        if genre not in data["genre_persona_map_entry"]:
            data["genre_persona_map_entry"][genre] = []

        if genre not in data["genre_checklists_entry"]:
            data["genre_checklists_entry"][genre] = {
                "editor": ["Rule 1...", "Rule 2..."],
                "moderator": ["RED if rule 1 fails.", "RED if rule 2 fails."]
            }

        # 3. Add Personas for this Genre
        while True:
            print(f"\n--- Adding Persona for Genre: '{genre}' ---")
            relationship = input("Enter Relationship Category (e.g., Artisans): ").strip()
            mood = input("Enter Mood Dynamic (e.g., Subtle Admirers): ").strip()

            # Merge Relationship to Genre Map
            if relationship not in data["genre_persona_map_entry"][genre]:
                data["genre_persona_map_entry"][genre].append(relationship)

            # Deep Merge Persona Pool
            if relationship not in data["persona_pool_entry"]:
                data["persona_pool_entry"][relationship] = {}

            data["persona_pool_entry"][relationship][mood] = {
                "Kaya": {
                    "role": "Role A",
                    "persona": "Description of Kaya's persona"
                },
                "Kolpo": {
                    "role": "Role B",
                    "persona": "Description of Kolpo's persona"
                }
            }

            another_persona = input(f"Add another persona for '{genre}'? (y/n): ").strip().lower()
            if another_persona != 'y':
                break

        another_genre = input("\nAdd another Genre? (y/n): ").strip().lower()
        if another_genre != 'y':
            break

    # Save merged data
    with open(FILE_NAME, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)

    print(f"\nSuccessfully merged and saved into: {FILE_NAME}")

if __name__ == "__main__":
    generate_nested_schema()
</file>

<file path="restart_services.sh">
#!/usr/bin/env bash
# Restart the top-level local-ai services.
#
#   chat-webui          python3 ./chat-webui.py                    (port 3001)
#   markdown hosting    uvicorn markdown_hosting:app               (port 3002)
#   code hosting        python3 code_host.py .                     (port 9000)
#   request tracker     sudo python3 server/track_dashboard.py     (port 8093)
#
# Every service runs under nohup and appends to its own log in logs/.
# The tracker reads /var/log/nginx/track.log, so it runs under sudo —
# you may be prompted for your password once.
set -u

REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GIT_DIR="$(dirname "$REPO_DIR")"
LOG_DIR="$REPO_DIR/logs"
mkdir -p "$LOG_DIR"

stop_all() {
    local pattern="$1" name="$2"
    if pgrep -f "$pattern" >/dev/null 2>&1; then
        pkill -TERM -f "$pattern" 2>/dev/null
        for _ in $(seq 1 20); do
            pgrep -f "$pattern" >/dev/null 2>&1 || break
            sleep 0.5
        done
        pkill -KILL -f "$pattern" 2>/dev/null
        echo "  stopped: $name"
    else
        echo "  not running: $name"
    fi
}

start_one() {
    local name="$1" logfile="$2" dir="$3"
    shift 3
    (
        cd "$dir" || exit 1
        nohup "$@" >>"$logfile" 2>&1 &
        echo "  started: $name (pid $!, log $logfile)"
    )
}

echo "== Stopping =="
stop_all 'chat-webui\.py'      "chat web ui"
stop_all 'markdown_hosting'    "markdown hosting"
stop_all 'code_host\.py'       "code hosting"
stop_all 'track_dashboard'     "request tracker"

# Tracker log must exist and be readable (same prep as local_cloud.sh).
sudo touch /var/log/nginx/track.log
sudo chmod 640 /var/log/nginx/track.log

echo "== Starting =="
start_one "chat web ui"     "$LOG_DIR/chat-webui.log"       "$REPO_DIR" python3 ./chat-webui.py
start_one "markdown hosting" "$LOG_DIR/markdown-hosting.log" "$REPO_DIR" python3 -m uvicorn markdown_hosting:app --host 127.0.0.1 --port 3002
start_one "code hosting"    "$LOG_DIR/code-host.log"        "$GIT_DIR"  python3 code_host.py .
start_one "request tracker" "$LOG_DIR/track-dashboard.log"  "$REPO_DIR" sudo python3 server/track_dashboard.py --port 8093 --log /var/log/nginx/track.log

sleep 3
echo "== Health =="
check() {
    local name="$1" url="$2"
    local code
    code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$url" 2>/dev/null)
    if [ "$code" != "000" ]; then
        echo "  UP   $name ($url -> HTTP $code)"
    else
        echo "  DOWN $name ($url) — check its log in $LOG_DIR/"
        [ "$name" = "request tracker" ] && \
            echo "       (tracker needs sudo; run this script from a terminal so the password can be prompted)"
    fi
}
check "chat web ui"      http://127.0.0.1:3001/
check "markdown hosting" http://127.0.0.1:3002/
check "code hosting"     http://127.0.0.1:9000/
check "request tracker"  http://127.0.0.1:8093/
</file>

<file path="sso-debugging.md">
# Here's a recap of the debugging session — you were fixing SSO/logout for `home.palashkantikundu.in/ai`:

**1. Initial 404 on `/ai/`**

- Root cause: `local-ai-authentik-outpost-1` was `unhealthy`, so `auth_request` calls to it failed and the login redirect chain dead-ended in a 404.

**2. Outpost `unhealthy`, config had a typo**

- `authentik-compose.yaml` had `UTHENTIK_TOKEN` (missing the leading `A`) instead of `AUTHENTIK_TOKEN` — the env var was silently ignored.

**3. `502 Bad Gateway` fetching outpost config**

- After fixing the typo, the outpost was trying to reach Authentik via the *public* URL (`https://home.palashkantikundu.in/sso/`), round-tripping out to the internet unnecessarily. Fixed by pointing `AUTHENTIK_HOST` at the internal Docker service name instead, and adding `AUTHENTIK_HOST_BROWSER` for user-facing redirects.

**4. `404 Not Found` fetching outpost config**

- `AUTHENTIK_HOST` was missing the `/sso/` path suffix that `AUTHENTIK_WEB__PATH: /sso/` requires on the server side. Fixed to `http://authentik-server:9000/sso/`.

**5. OAuth "Redirect URI Error"**

- The Proxy Provider's **External Host** field in the Authentik admin UI was pointing at an internal address instead of `https://home.palashkantikundu.in`. Fixed in the admin UI, then restarted the outpost to re-sync.

**6. 502 on the app itself**

- Traced to checking whether `chat-webui.py` (port 3001) was actually running — this resolved once confirmed/started.

**7. Login worked. Logout redirected to `/sso/outpost.goauthentik.io/end?rd=/` → 404**

- Found the bug in `src/api.js`: the logout URL was hardcoded with an incorrect `/sso/` prefix. Nginx only routes `/outpost.goauthentik.io/*` at the domain root, not under `/sso/`. Fixed the JS to `window.location.assign('/outpost.goauthentik.io/end?rd=/')` and rebuilt the frontend.

**8. Still 404 on `/outpost.goauthentik.io/end?rd=/` — unresolved**

- Confirmed via nginx access log that nginx is routing this correctly (the 404 comes from the outpost itself, not nginx).
- Confirmed via direct `curl` to `127.0.0.1:9010` (bypassing nginx) that the outpost returns a bare `404 page not found` for this path.
- Current working theory: the `/end` logout endpoint needs the browser's Authentik session cookie to identify which provider/session to terminate, and it may not be present/forwarded on that request. **Next steps left open:** check dev tools for the `authentik_proxy_*` cookie on that request, and retest the curl call with that cookie attached to confirm.

That last piece (logout 404) is where we left off — still open.
</file>

<file path="prompts/genre_checklists.json">
{
  "default": {
    "recommended_turns": 3,
    "editor": [
      "Ensure the story actually delivers the assigned task and any task details verbatim, not a loosely related substitute.",
      "Ensure the story reaches a clear, complete ending rather than trailing off."
    ],
    "moderator": [
      "RED if the story does not deliver the assigned task, or ignores the task details field.",
      "RED if the story ends abruptly without resolution."
    ]
  },
  "Bedtime Stories": {
    "recommended_turns": 3,
    "editor": [
      "If the task details asked for a web-searched, kid-appropriate event as the story's basis, confirm a web_search was actually performed (check the [WEB SEARCH REPORTS SHARED] handoffs) and that the chosen event is reflected in the plot — flag if not.",
      "Confirm the content is appropriate for children aged 5-12: no violence, fear, death, or romance beyond the mildest hand-holding.",
      "Confirm the story ends on a calm, reassuring, sleep-appropriate note, not a cliffhanger or exciting climax.",
      "If 'image' is a declared medium, confirm exactly one warm, child-friendly illustration is embedded and matches the scene it's placed next to."
    ],
    "moderator": [
      "RED if the story is not clearly appropriate for ages 5-12 (any violence, scariness, or mature themes).",
      "RED if the ending is exciting/unresolved rather than calm and reassuring.",
      "RED if the task details required a web-searched real event and the story shows no evidence a search actually happened or informed the plot.",
      "RED if 'image' was declared but is missing."
    ]
  },
  "Satire": {
    "recommended_turns": 3,
    "editor": [
      "Confirm the piece stays generic/institutional (corporate work culture as an institution) and does not name or clearly caricature a real, identifiable company or person.",
      "Confirm there is an actual comedic/satirical thesis, not just a list of complaints presented as prose.",
      "Confirm no fabricated quotes are attributed to real people.",
      "Confirm the humor targets workplace institutions/behaviors, not a protected group."
    ],
    "moderator": [
      "RED if any real, identifiable company or person is named or clearly caricatured.",
      "RED if the piece reads as a sincere complaint/rant rather than satire with a discernible comedic angle.",
      "RED if a fabricated quote is attributed to a real person.",
      "RED if the humor targets a protected group rather than the workplace institution."
    ]
  },
  "Food & Recipes": {
    "recommended_turns": 3,
    "editor": [
      "Confirm the story/guide contains an actual ingredient list and numbered preparation steps, not just descriptive prose about the dish.",
      "Confirm the dish is a real, identifiable traditional festival sweet (or clearly-labeled fictional variant), not an invented dish falsely claimed as traditional.",
      "If 'image' is a declared medium, confirm the embedded image plausibly matches the described finished dish or a key preparation step.",
      "Confirm quantities/measurements are given for every listed ingredient."
    ],
    "moderator": [
      "RED if there is no clear ingredient list or no numbered steps.",
      "RED if 'image' was declared but is missing, or the image clearly does not match the described dish.",
      "RED if the recipe is presented as traditional but is entirely fabricated with no real basis."
    ]
  },
  "News": {
    "recommended_turns": 2,
    "editor": [
      "Confirm every factual claim traces back to a web_search/fetch_page result actually retrieved in this conversation (check the Citations & References section is non-empty and each claim maps to a citation).",
      "Confirm no invented statistics, dates, or events are present beyond what the search results support.",
      "Confirm a 'daily summary' covers multiple distinct news items, not a single deep dive presented as a summary.",
      "Confirm neutral, reportorial tone — no editorializing presented as fact."
    ],
    "moderator": [
      "RED if there are no citations despite the piece presenting itself as a news summary.",
      "RED if any claim looks fabricated or unsupported by the collected search results.",
      "RED if only one topic is covered when the task calls for a 'summary of top updates' (plural).",
      "RED if the tone is opinionated/editorial rather than reportorial."
    ]
  },
  "Adventure & Horror": {
    "recommended_turns": 4,
    "editor": [
      "Confirm the narrative maintains high suspense without violating general platform safety bounds (no graphic gore or self-harm).",
      "If based on real events, confirm a web search was executed and key historical details remain accurate.",
      "Confirm the story reaches a distinct resolution or intentional thriller climax rather than cutting off mid-scene."
    ],
    "moderator": [
      "RED if the horror content depicts extreme graphic gore, gratuitous cruelty, or self-harm.",
      "RED if a real-world factual task was requested but no search occurred or facts were completely fabricated.",
      "RED if the narrative ends abruptly mid-action."
    ]
  },
  "Sci-Fi": {
    "recommended_turns": 4,
    "editor": [
      "Verify the core sci-fi concept or futuristic technology follows consistent internal logic.",
      "Ensure technical jargon enhances the atmosphere without rendering the narrative unreadable."
    ],
    "moderator": [
      "RED: World-building rules directly contradict previous turns.",
      "RED: Scientific/futuristic elements are introduced as key plot devices without context."
    ]
  },
  "Thriller": {
    "recommended_turns": 4,
    "editor": [
      "Ensure high pacing, claustrophobic tension, and suspense are maintained across all turns.",
      "Verify that the resolution resolves the primary threat or delivers a deliberate thriller cliffhanger."
    ],
    "moderator": [
      "RED: Tension drops abruptly into mundane dialogue without narrative justification.",
      "RED: The story ends mid-action without resolving the active threat or setting up a cliffhanger."
    ]
  },
  "Detective": {
    "recommended_turns": 5,
    "editor": [
      "Confirm all crucial clues needed to solve the mystery are presented to the reader before the final reveal.",
      "Ensure logical deduction is clearly demonstrated during the investigation phase."
    ],
    "moderator": [
      "RED: The mystery resolution relies on evidence or characters never mentioned in earlier turns.",
      "RED: Deductive conclusions are completely illogical or contradict established clues."
    ]
  },
  "Adventure": {
    "recommended_turns": 4,
    "editor": [
      "Ensure environmental hazards and physical stakes drive the narrative forward.",
      "Verify dynamic interaction between characters as they navigate obstacles."
    ],
    "moderator": [
      "RED: The setting lacks environmental grounding or sense of physical progression.",
      "RED: Conflict is resolved instantly without effort or risk."
    ]
  },
  "News Bytes": {
    "recommended_turns": 2,
    "editor": [
      "Ensure facts directly map to retrieved search results with clear, precise summaries.",
      "Maintain a strictly neutral, concise, and reportorial tone."
    ],
    "moderator": [
      "RED: Factual claims are fabricated or unverified.",
      "RED: Missing required source citations or web research backing."
    ]
  },
  "News Turned Stories": {
    "recommended_turns": 4,
    "editor": [
      "Verify the narrative adaptation retains core real-world facts while building compelling human drama.",
      "Ensure character dialogue and emotional beats feel grounded and natural."
    ],
    "moderator": [
      "RED: Core factual premises from the news source are completely distorted.",
      "RED: The story sensationalizes real events into harmful or misleading fiction."
    ]
  }
}
</file>

<file path="prompts/genre_persona_map.json">
{
  "Bedtime Stories": [
    "Parent & Child",
    "Siblings",
    "Married Couple"
  ],
  "Satire": [
    "Colleagues",
    "Friends"
  ],
  "Food & Recipes": [
    "Married Couple",
    "Parent & Child",
    "Friends",
    "Siblings"
  ],
  "News": [
    "Colleagues",
    "Friends"
  ],
  "Adventure & Horror": [
    "Travel Companions",
    "Friends",
    "Siblings",
    "Married Couple"
  ],
  "Sci-Fi": [
    "Colleagues",
    "Travel Companions",
    "Friends",
    "Married Couple"
  ],
  "Thriller": [
    "Colleagues",
    "Friends",
    "Married Couple",
    "Siblings"
  ],
  "Detective": [
    "Colleagues",
    "Friends"
  ],
  "Adventure": [
    "Travel Companions",
    "Friends",
    "Siblings",
    "Married Couple"
  ],
  "News Bytes": [
    "Colleagues"
  ],
  "News Turned Stories": [
    "Colleagues",
    "Friends",
    "Siblings"
  ],
  "default": [
    "Colleagues",
    "Friends"
  ]
}
</file>

<file path="prompts/persona_pool.json">
{
  "Parent & Child": {
    "Nurturing Guidance": {
      "required_role": "free",
      "Kaya": {
        "role": "Daughter",
        "persona": "Enthusiastic, curious, seeking advice with bright questions"
      },
      "Kolpo": {
        "role": "Father",
        "persona": "Patient, wise, reassuring, giving grounded life lessons"
      }
    },
    "Bedtime Storytelling": {
      "required_role": "free",
      "Kaya": {
        "role": "Mother",
        "persona": "Warm, gentle storyteller with a whimsical and soothing touch"
      },
      "Kolpo": {
        "role": "Son",
        "persona": "Sleepy, imaginative listener asking playful 'what-if' questions"
      }
    }
  },
  "Siblings": {
    "Playful Rivalry": {
      "required_role": "free",
      "Kaya": {
        "role": "Younger Sister",
        "persona": "Witty, teasing, quick-tongued sparkplug trying to outsmart"
      },
      "Kolpo": {
        "role": "Older Brother",
        "persona": "Protective, mildly exasperated, calm yet competitive"
      }
    },
    "Shared Secrets": {
      "required_role": "free",
      "Kaya": {
        "role": "Sister",
        "persona": "Conspiratorial, expressive, whispering eager details"
      },
      "Kolpo": {
        "role": "Brother",
        "persona": "Reliable vault, analytical strategist helping plan the secret"
      }
    },
    "Survival Alliance": {
      "required_role": "premium",
      "Kaya": {
        "role": "Sister (Scout)",
        "persona": "Quick-thinking, resource-frugal, highly observant under physical stress"
      },
      "Kolpo": {
        "role": "Brother (Anchor)",
        "persona": "Steadfast, pragmatic shield, calculating safe routes and risks"
      }
    }
  },
  "Married Couple": {
    "Quiet Intimacy": {
      "required_role": "premium",
      "Kaya": {
        "role": "Wife",
        "persona": "Affectionate, observant, noticing subtle emotional details"
      },
      "Kolpo": {
        "role": "Husband",
        "persona": "Grounded, warm, deeply supportive with quiet strength"
      }
    },
    "Playful Banter": {
      "required_role": "free",
      "Kaya": {
        "role": "Wife",
        "persona": "Tangy, sassy, tossing quick jokes over daily chores"
      },
      "Kolpo": {
        "role": "Husband",
        "persona": "Charming, dry-witted, playing along with effortless grace"
      }
    },
    "Under Crisis": {
      "required_role": "premium",
      "Kaya": {
        "role": "Wife (Crisis Lead)",
        "persona": "Decisive, hyper-focused, protective, prioritizing survival logistics"
      },
      "Kolpo": {
        "role": "Husband (Stabilizer)",
        "persona": "Calm emotional anchor, vigilant, executing high-stakes maneuvers"
      }
    }
  },
  "Colleagues": {
    "Focused Collaboration": {
      "required_role": "free",
      "Kaya": {
        "role": "Creative Lead",
        "persona": "Fast-paced, bold idea generator pushing boundaries"
      },
      "Kolpo": {
        "role": "Technical Architect",
        "persona": "Methodical, structured, turning wild ideas into reality"
      }
    },
    "Deadline Stress": {
      "required_role": "free",
      "Kaya": {
        "role": "Project Manager",
        "persona": "High-energy, witty troubleshooter keeping morale up"
      },
      "Kolpo": {
        "role": "Lead Specialist",
        "persona": "Cool under pressure, razor-focused, crunching through blocks"
      }
    },
    "Investigative Inquiry": {
      "required_role": "premium",
      "Kaya": {
        "role": "Lead Detective / Researcher",
        "persona": "Relentless, highly intuitive, connecting disparate evidence threads"
      },
      "Kolpo": {
        "role": "Forensic / Data Analyst",
        "persona": "Skeptical, precise, verifying facts against hard raw data"
      }
    },
    "News Anchor Desk": {
      "required_role": "free",
      "Kaya": {
        "role": "Senior News Anchor",
        "persona": "Crisp, authoritative, adept at rapid live synthesized updates"
      },
      "Kolpo": {
        "role": "Field Correspondent",
        "persona": "Direct, fast-paced reporter providing verified ground details"
      }
    },
    "Futuristic Research": {
      "required_role": "free",
      "Kaya": {
        "role": "Chief Technologist",
        "persona": "Visionary, speculative thinker probing technological ethics"
      },
      "Kolpo": {
        "role": "Systems Systems Engineer",
        "persona": "Pragmatic engineer, testing mechanical bounds and technical feasibility"
      }
    }
  },
  "Friends": {
    "Casual Venting": {
      "required_role": "free",
      "Kaya": {
        "role": "Best Friend (F)",
        "persona": "Empathic, expressive, sharp humor to break tension"
      },
      "Kolpo": {
        "role": "Best Friend (M)",
        "persona": "Grounded listener, offering solid advice and perspective"
      }
    },
    "Nostalgic Reminiscing": {
      "required_role": "free",
      "Kaya": {
        "role": "Childhood Friend (F)",
        "persona": "Vivid storyteller, digging up hilarious past memories"
      },
      "Kolpo": {
        "role": "Childhood Friend (M)",
        "persona": "Reflective, laughing along, filling in forgotten details"
      }
    },
    "Urban Exploration": {
      "required_role": "free",
      "Kaya": {
        "role": "Adventurous Friend",
        "persona": "Curious thrill-seeker, pushing past comfort zones to uncover stories"
      },
      "Kolpo": {
        "role": "Cautious Friend",
        "persona": "Voice of reason, double-checking facts and ensuring safety"
      }
    }
  },
  "Travel Companions": {
    "Shared Exploration": {
      "required_role": "free",
      "Kaya": {
        "role": "Explorer",
        "persona": "Spontaneous navigator, eager to discover hidden local spots"
      },
      "Kolpo": {
        "role": "Planner",
        "persona": "Resourceful, calm driver, keeping logistics and safety intact"
      }
    },
    "High-Stakes Expedition": {
      "required_role": "premium",
      "Kaya": {
        "role": "Field Expedition Lead",
        "persona": "Resilient, bold, adapting rapidly to harsh environments"
      },
      "Kolpo": {
        "role": "Navigation Specialist",
        "persona": "Vigilant tracker, calculating terrain risks and environmental factors"
      }
    }
  }
}
</file>

<file path="searxng/settings.yml">
use_default_settings: true

server:
  secret_key: "ultrasecretkeyforsearxng"
  base_url: false
  limiter: false

search:
  safe_search: 0
  autocomplete: ""
  formats:
    - html
    - json

outgoing:
  user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
  request_timeout: 10.0
  max_request_timeout: 15.0

engines:
  - name: wikidata
    engine: dummy
    disabled: true
    language_support: true

  - name: ahmia
    disabled: true
</file>

<file path="server/features/themes_db.py">
"""SQLite-backed theme & combination tracker (``track_theme`` tool, /api/themes).

A dedicated ledger that guarantees creative variety across generated content
without polluting per-user contexts or the to-do (``manage_tasks``) system.

Two dimensions are tracked:

* ``scope`` — the user whose content is being generated. Records inside one
  scope are that user's *combination* history: every already-used combination
  of detail fields + mood + genre + role + persona.
* global   — the union of ALL scopes ("vastly keep track of all the users"),
  used to avoid cross-user repetition and to see overall output.

Within a self-chat window every participant (kolpo, kaya, editor, moderator)
shares a single scope (``"self-chat"``) so the theme stays coordinated between
the users of that window while regular per-user chats stay isolated.
"""

import hashlib
import json
import sqlite3
import threading
import uuid
from datetime import datetime

from server.features.state import M

_themes_db_lock = threading.Lock()


def _db_run(query, params=()):
    with _themes_db_lock:
        conn = sqlite3.connect(M.THEMES_DB)
        try:
            cur = conn.execute(query, params)
            conn.commit()
            return cur.rowcount
        finally:
            conn.close()


def _db_fetch(query, params=()):
    with _themes_db_lock:
        conn = sqlite3.connect(M.THEMES_DB)
        conn.row_factory = sqlite3.Row
        try:
            cur = conn.execute(query, params)
            return [dict(r) for r in cur.fetchall()]
        finally:
            conn.close()


def _db_fetch_one(query, params=()):
    rows = _db_fetch(query, params)
    return rows[0] if rows else None


def _init_themes_db():
    _db_run(
        """
        CREATE TABLE IF NOT EXISTS theme_log (
            id TEXT PRIMARY KEY,
            scope TEXT NOT NULL,
            user_id TEXT DEFAULT '',
            genre TEXT DEFAULT '',
            mood TEXT DEFAULT '',
            role TEXT DEFAULT '',
            persona TEXT DEFAULT '',
            details TEXT DEFAULT '{}',
            combo_hash TEXT NOT NULL,
            theme TEXT DEFAULT '',
            status TEXT DEFAULT 'active',
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL,
            UNIQUE(scope, combo_hash)
        )
        """
    )


def combo_hash(genre, mood, role, persona, details=None, level="round"):
    """Canonical fingerprint of a combination.

    The combination mixes every detail field with mood, genre, role and
    persona, so reusing ANY of them with the same set counts as a duplicate.
    ``level`` separates round-scoped records from per-turn records so they
    never falsely collide.
    """
    payload = {
        "genre": genre or "",
        "mood": mood or "",
        "role": role or "",
        "persona": persona or "",
        "details": details or {},
        "level": level or "round",
    }
    canonical = json.dumps(payload, sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:24]


def theme_log_create(
    scope,
    user_id="",
    genre="",
    mood="",
    role="",
    persona="",
    details=None,
    theme="",
    status="active",
    level="round",
):
    if isinstance(details, dict):
        details = json.dumps(details, ensure_ascii=False, sort_keys=True)
    if details in (None, ""):
        details = "{}"
    try:
        details_obj = json.loads(details)
    except (TypeError, ValueError):
        details_obj = {}
    h = combo_hash(genre, mood, role, persona, details_obj, level)

    existing = _db_fetch_one(
        "SELECT * FROM theme_log WHERE scope=? AND combo_hash=?",
        (scope, h),
    )
    if existing:
        updates = {}
        if theme and theme != existing.get("theme"):
            updates["theme"] = theme
        if status and existing.get("status") != status:
            updates["status"] = status
        if updates:
            updates["updated_at"] = datetime.now().isoformat(timespec="seconds")
            set_clause = ", ".join(f"{k}=?" for k in updates)
            _db_run(
                f"UPDATE theme_log SET {set_clause} WHERE id=?",
                tuple(updates.values()) + (existing["id"],),
            )
            existing = _db_fetch_one(
                "SELECT * FROM theme_log WHERE id=?", (existing["id"],)
            )
        return existing, True

    tid = str(uuid.uuid4())
    now = datetime.now().isoformat(timespec="seconds")
    _db_run(
        "INSERT INTO theme_log (id, scope, user_id, genre, mood, role, persona, details, combo_hash, theme, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
        (
            tid,
            scope,
            user_id or "",
            genre or "",
            mood or "",
            role or "",
            persona or "",
            details,
            h,
            theme or "",
            status or "active",
            now,
            now,
        ),
    )
    return _db_fetch_one("SELECT * FROM theme_log WHERE id=?", (tid,)), False


def theme_log_complete(tid):
    existing = _db_fetch_one("SELECT * FROM theme_log WHERE id=?", (tid,))
    if not existing:
        return None
    _db_run(
        "UPDATE theme_log SET status='completed', updated_at=? WHERE id=?",
        (datetime.now().isoformat(timespec="seconds"), tid),
    )
    return _db_fetch_one("SELECT * FROM theme_log WHERE id=?", (tid,))


def theme_log_list(scope=None, all_scopes=False, status=None, limit=50):
    where = []
    params = []
    if not all_scopes and scope:
        where.append("scope=?")
        params.append(scope)
    if status:
        where.append("status=?")
        params.append(status)
    clause = ("WHERE " + " AND ".join(where)) if where else ""
    try:
        limit = max(1, int(limit))
    except (TypeError, ValueError):
        limit = 50
    params.append(limit)
    return _db_fetch(
        f"SELECT * FROM theme_log {clause} ORDER BY created_at DESC LIMIT ?",
        params,
    )


def theme_log_check(scope, genre="", mood="", role="", persona="", details=None, level="round"):
    if isinstance(details, str):
        try:
            details = json.loads(details)
        except (TypeError, ValueError):
            details = {}
    h = combo_hash(genre, mood, role, persona, details or {}, level)
    return _db_fetch_one(
        "SELECT * FROM theme_log WHERE scope=? AND combo_hash=?",
        (scope, h),
    )


def theme_log_stats(scope=None, all_scopes=False):
    where = []
    params = []
    if not all_scopes and scope:
        where.append("scope=?")
        params.append(scope)
    clause = ("WHERE " + " AND ".join(where)) if where else ""
    rows = _db_fetch(
        f"SELECT scope, status, COUNT(*) AS count FROM theme_log {clause} GROUP BY scope, status ORDER BY scope",
        params,
    )
    totals = {}
    for r in rows:
        totals[r["scope"]] = totals.get(r["scope"], {})
        totals[r["scope"]][r["status"]] = r["count"]
    return {
        "total": sum(r["count"] for r in rows),
        "per_scope": totals,
    }


def handle_theme_tool(user_id, args):
    op = args.get("operation", "")
    scope = (args.get("scope") or "").strip() or None

    if op == "log":
        if not scope:
            return json.dumps({"ok": False, "error": "Missing required argument: scope"})
        record, duplicate = theme_log_create(
            scope,
            user_id=user_id,
            genre=args.get("genre"),
            mood=args.get("mood"),
            role=args.get("role"),
            persona=args.get("persona"),
            details=args.get("details"),
            theme=args.get("theme"),
            status=args.get("status"),
            level=args.get("level", "round"),
        )
        return json.dumps({"ok": True, "duplicate": duplicate, "theme": record})
    elif op == "complete":
        tid = args.get("theme_id")
        if not tid:
            return json.dumps({"ok": False, "error": "Missing required argument: theme_id"})
        record = theme_log_complete(tid)
        if record:
            return json.dumps({"ok": True, "theme": record})
        return json.dumps({"ok": False, "error": "Theme not found"})
    elif op == "check":
        if not scope:
            return json.dumps({"ok": False, "error": "Missing required argument: scope"})
        record = theme_log_check(
            scope,
            genre=args.get("genre"),
            mood=args.get("mood"),
            role=args.get("role"),
            persona=args.get("persona"),
            details=args.get("details"),
            level=args.get("level", "round"),
        )
        return json.dumps(
            {
                "ok": True,
                "used": bool(record),
                "theme": record,
            }
        )
    elif op == "list":
        records = theme_log_list(
            scope=scope,
            all_scopes=bool(args.get("global")),
            status=args.get("status"),
            limit=args.get("limit", 50),
        )
        return json.dumps({"ok": True, "themes": records})
    elif op == "stats":
        return json.dumps(
            {"ok": True, **theme_log_stats(scope=scope, all_scopes=bool(args.get("global")))}
        )
    return json.dumps({"ok": False, "error": f"Unknown operation: {op}"})
</file>

<file path="server/features/users.py">
"""Per-user context files and HTTP identity resolution.

Identity comes from :mod:`server.auth` — Authentik is the single identity
provider (there is no users.json anymore). This module keeps the names the
chat engine calls: password lookup is gone, context paths are derived from the
username, and ``get_current_user``/``get_current_identity`` resolve the
request's identity through the shared auth layer.
"""

import os
import re
import time
from datetime import datetime

from server.auth import get_current_user as auth_get_current_user
from server.auth import get_identity as auth_get_identity
from server.config import CONTEXTS_DIR
from server.features.state import M


def _safe_username(user):
    safe = re.sub(r"[^A-Za-z0-9_-]", "_", user or "")
    return safe or "unknown"


def _mark_seen(username):
    """Record a username as recently active (heartbeat for active-user display)."""
    if not username:
        return
    with M._user_last_seen_lock:
        M._user_last_seen[username] = time.time()


def get_current_user(headers):
    username = auth_get_current_user(headers)
    _mark_seen(username)
    return username


def get_current_identity(headers):
    identity = auth_get_identity(headers)
    if identity:
        _mark_seen(identity["username"])
    return identity


def active_users(window_seconds=120, exclude_agents=True):
    """Sorted usernames seen within the window, optionally excluding agents."""
    now = time.time()
    with M._user_last_seen_lock:
        users = sorted(
            u for u, ts in M._user_last_seen.items()
            if now - ts <= window_seconds
        )
    if exclude_agents:
        with M._tokens_lock:
            agent_users = set(M._agent_users)
        return [u for u in users if u not in agent_users]
    return users


def get_user_context_path(username):
    """Derive the context file path from the username.

    users.json previously stored an arbitrary per-user ``context_file`` path;
    with Authentik as the sole identity store that field no longer exists, so
    every user's context lives at ``CONTEXTS_DIR/<username>.txt``.
    """
    return os.path.join(CONTEXTS_DIR, _safe_username(username) + ".txt")


def read_user_context(username):
    path = get_user_context_path(username)
    print("Context path", path, "for", username)
    if path and os.path.exists(path):
        try:
            print("Reading", path)
            with open(path) as f:
                context = f.read()
                print(context)
                return context
        except:
            return ""
    return ""


def write_user_context(username, content):
    path = get_user_context_path(username)
    if path:
        os.makedirs(os.path.dirname(path), exist_ok=True)
        existing = read_user_context(username)
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
        entry = f"[{timestamp}] {content}"
        new_content = (existing.strip() + "\n\n" + entry) if existing.strip() else entry
        with open(path, "w") as f:
            f.write(new_content)
</file>

<file path="server/track_dashboard.py">
#!/usr/bin/env python3
"""Request tracking dashboard for the local nginx.

Reads the custom nginx access log (json lines, one per request) written by
local_cloud.sh (log_format `track`), aggregates per-request metadata, and
serves a minimal dashboard.

What it answers (from nginx's own request metadata):
  - Did the request come from GCP?          -> X-Via-GCP header ($http_x_via_gcp)
  - What request was made?                  -> method + URI + status
  - Outbound egress per request             -> $bytes_sent (bytes sent to client)
  - Inbound  egress per request             -> $request_length (bytes from client)
  - Which app initiated it?                 -> user-agent classification (Android
                                              app / iOS app / desktop app / browser / CLI)

Run:            python3 server/track_dashboard.py [--port 8093] [--log PATH]
Serve via nginx: location /track/ { proxy_pass http://127.0.0.1:8093/; }
"""

import argparse
import json
import os
import threading
import time
from collections import Counter, defaultdict
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse

DEFAULT_LOG = "/var/log/nginx/track.log"
DEFAULT_PORT = 8093

APP_PATTERNS = [
    ("Nextcloud Android", "nextcloud-android"),
    ("Nextcloud iOS", "nextcloud-ios"),
    ("Nextcloud Desktop", "nextcloud-desktop"),
    ("Nextcloud DAVx5", "davx5"),
    ("curl", "curl"),
    ("wget", "wget"),
    ("Python requests", "python-requests"),
    ("Rclone/Nextcloud", "rclone"),
    ("Browser Chrome", "chrome"),
    ("Browser Firefox", "firefox"),
    ("Browser Safari", "safari"),
    ("Browser Edge", "edg"),
    ("Browser (other Mozilla)", "mozilla"),
]


def classify_app(ua):
    ua = (ua or "").lower()
    if "nextcloud-android" in ua:
        return "Nextcloud Android app"
    if "nextcloud-ios" in ua or "nextcloudmobile" in ua:
        return "Nextcloud iOS app"
    if "nextcloud-desktop" in ua:
        return "Nextcloud Desktop app"
    for label, needle in APP_PATTERNS:
        if needle in ua:
            return label
    if ua:
        return "Unknown client"
    return "(no user-agent)"


def status_class(status):
    try:
        s = int(status)
    except (TypeError, ValueError):
        return "?"
    return f"{s // 100}xx"


class Tracker:
    def __init__(self, log_path, keep_entries=5000):
        self.path = log_path
        self.keep = keep_entries
        self.lock = threading.Lock()
        self.reset()

    def reset(self):
        self.total = 0
        self.gcp_total = 0
        self.by_app = Counter()
        self.by_uri_top = Counter()
        self.by_method = Counter()
        self.by_status = Counter()
        self.out_bytes = {"all": 0, "gcp": 0, "non_gcp": 0}
        self.in_bytes = {"all": 0, "gcp": 0, "non_gcp": 0}
        self.recent = []
        self.start_ts = time.time()
        self.last_ts = None

    def _ingest(self, raw):
        try:
            r = json.loads(raw)
        except (ValueError, TypeError):
            return
        self.total += 1
        gcp = str(r.get("gcp") or "").strip().lower()
        is_gcp = gcp in ("1", "true", "yes", "on")
        if is_gcp:
            self.gcp_total += 1

        ua = r.get("ua") or ""
        app = classify_app(ua)
        self.by_app[app] += 1

        uri = r.get("uri") or "/"
        self.by_uri_top[uri] += 1

        method = r.get("method") or "-"
        self.by_method[method] += 1

        status = r.get("status") or 0
        self.by_status[status_class(status)] += 1

        out = int(r.get("out") or 0)
        inc = int(r.get("in") or 0)
        bucket = "gcp" if is_gcp else "non_gcp"
        self.out_bytes["all"] += out
        self.out_bytes[bucket] += out
        self.in_bytes["all"] += inc
        self.in_bytes[bucket] += inc

        ts = r.get("time") or time.strftime("%Y-%m-%dT%H:%M:%S%z")
        self.last_ts = ts
        entry = {
            "time": ts,
            "gcp": is_gcp,
            "app": app,
            "method": method,
            "uri": uri,
            "status": status,
            "in": inc,
            "out": out,
        }
        self.recent.append(entry)
        if len(self.recent) > self.keep:
            self.recent = self.recent[-self.keep:]

    def poll(self):
        """Read newly appended lines from the log and ingest them."""
        if not os.path.exists(self.path):
            return
        pos = getattr(self, "_offset", 0)
        size = os.path.getsize(self.path)
        if size < pos:
            # rotated/truncated — restart from the beginning
            pos = 0
        with open(self.path, "rb") as f:
            f.seek(pos)
            lines = f.read()
            self._offset = f.tell()
        for raw in lines.decode("utf-8", "replace").splitlines():
            self._ingest(raw)

    def stats(self):
        with self.lock:
            self.poll()
            top_uri = self.by_uri_top.most_common(25)
            return {
                "generated": time.strftime("%Y-%m-%d %H:%M:%S"),
                "uptime_secs": int(time.time() - self.start_ts),
                "last_seen": self.last_ts,
                "total": self.total,
                "gcp_total": self.gcp_total,
                "non_gcp_total": self.total - self.gcp_total,
                "in_bytes": dict(self.in_bytes),
                "out_bytes": dict(self.out_bytes),
                "by_app": self.by_app.most_common(),
                "by_method": self.by_method.most_common(),
                "by_status": self.by_status.most_common(),
                "top_uri": top_uri,
                "recent": list(reversed(self.recent[-200:])),
            }


TRACKER = None


class Handler(BaseHTTPRequestHandler):
    def log_message(self, *a):
        pass

    def _send(self, code, content_type, body):
        self.send_response(code)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        path = urlparse(self.path).path
        if path == "/api/stats":
            data = json.dumps(TRACKER.stats()).encode()
            self._send(200, "application/json", data)
        elif path in ("/", "/index.html"):
            self._send(200, "text/html; charset=utf-8", PAGE.encode())
        else:
            self._send(404, "text/plain", b"not found")


PAGE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Request Tracker</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0d1117; color: #c9d1d9; padding: 24px; }
h1 { color: #58a6ff; font-size: 22px; margin-bottom: 4px; }
.sub { color: #8b949e; font-size: 13px; margin-bottom: 20px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 20px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 16px; }
.card .label { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: #8b949e; }
.card .val { font-size: 26px; font-weight: 700; color: #58a6ff; }
.card .subval { font-size: 12px; color: #8b949e; margin-top: 2px; }
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; }
.panel { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 16px; margin-bottom: 14px; }
.panel h3 { color: #58a6ff; font-size: 14px; margin-bottom: 10px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #21262d; }
th { color: #8b949e; font-weight: 600; }
.bar { display: inline-block; height: 10px; border-radius: 3px; background: #1f6feb; vertical-align: middle; }
.bar-gcp { background: #f85149; }
.gcp-yes { color: #f85149; font-weight: 600; }
.gcp-no  { color: #3fb950; font-weight: 600; }
tr.recent:hover td { background: #1c2128; }
@media (prefers-reduced-motion: reduce) { * { transition: none; } }
</style>
</head>
<body>
<h1>Request Tracker</h1>
<div class="sub" id="meta">loading…</div>
<div class="grid" id="cards"></div>
<div class="cols">
  <div class="panel"><h3>By app / initiator</h3><table id="appTable"></table></div>
  <div class="panel"><h3>Top URIs</h3><table id="uriTable"></table></div>
  <div class="panel"><h3>Methods</h3><table id="methodTable"></table></div>
  <div class="panel"><h3>Status</h3><table id="statusTable"></table></div>
</div>
<div class="panel"><h3>Recent requests</h3><table id="recentTable"></table></div>
<script>
function fmtBytes(n) {
  if (n >= 1<<30) return (n/(1<<30)).toFixed(2)+' GiB';
  if (n >= 1<<20) return (n/(1<<20)).toFixed(2)+' MiB';
  if (n >= 1<<10) return (n/(1<<10)).toFixed(1)+' KiB';
  return n+' B';
}
function barRow(label, count, total, color) {
  const w = total ? (count/total*100) : 0;
  return '<tr><td>'+label+'</td><td>'+count+'</td><td><span class="bar '+(color||'')+'" style="width:'+w.toFixed(1)+'%"></span></td></tr>';
}
function render(s) {
  const gcpPct = s.total ? (s.gcp_total/s.total*100).toFixed(1) : '0';
  document.getElementById('meta').textContent =
    'Generated '+s.generated+' · tracking for '+Math.floor(s.uptime_secs/60)+' min · last request '+s.last_seen;
  const cards = [
    {label:'Total requests', val:s.total},
    {label:'From GCP', val:s.gcp_total, sub:gcpPct+'% of all', cls:'gcp-yes'},
    {label:'Not GCP', val:s.non_gcp_total, cls:'gcp-no', sub:(100-parseFloat(gcpPct)).toFixed(1)+'% of all'},
    {label:'Inbound (from clients)', val:fmtBytes(s.in_bytes.all), sub:'GCP '+fmtBytes(s.in_bytes.gcp)},
    {label:'Outbound (to clients)', val:fmtBytes(s.out_bytes.all), sub:'GCP '+fmtBytes(s.out_bytes.gcp)},
  ];
  document.getElementById('cards').innerHTML = cards.map(c =>
    '<div class="card"><div class="label">'+c.label+'</div><div class="val '+(c.cls||'')+'">'+c.val+'</div><div class="subval">'+(c.sub||'')+'</div></div>'
  ).join('');
  const total = s.total || 1;
  document.getElementById('appTable').innerHTML =
    '<tr><th>App</th><th>Requests</th><th style="width:40%"></th></tr>' +
    s.by_app.map(r=>barRow(r[0],r[1],total)).join('');
  document.getElementById('uriTable').innerHTML =
    '<tr><th>URI</th><th>Requests</th><th style="width:40%"></th></tr>' +
    s.top_uri.map(r=>barRow(r[0],r[1],total)).join('');
  document.getElementById('methodTable').innerHTML =
    '<tr><th>Method</th><th>Requests</th><th style="width:40%"></th></tr>' +
    s.by_method.map(r=>barRow(r[0],r[1],total)).join('');
  document.getElementById('statusTable').innerHTML =
    '<tr><th>Class</th><th>Requests</th><th style="width:40%"></th></tr>' +
    s.by_status.map(r=>barRow(r[0],r[1],total)).join('');
  document.getElementById('recentTable').innerHTML =
    '<tr><th>Time</th><th>GCP</th><th>App</th><th>Method</th><th>URI</th><th>Status</th><th>Inb</th><th>Out</th></tr>' +
    s.recent.map(r =>
      '<tr class="recent"><td>'+r.time+'</td><td class="'+(r.gcp?'gcp-yes':'gcp-no')+'">'+(r.gcp?'yes':'no')+'</td><td>'+(r.app||'')+'</td><td>'+r.method+'</td><td>'+r.uri+'</td><td>'+r.status+'</td><td>'+fmtBytes(r.in)+'</td><td>'+fmtBytes(r.out)+'</td></tr>'
    ).join('');
}
function refresh() {
  fetch('/track/api/stats').then(r=>r.json()).then(render).catch(()=>{});
}
refresh();
setInterval(refresh, 5000);
</script>
</body>
</html>
"""


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--port", type=int, default=DEFAULT_PORT)
    parser.add_argument("--log", default=DEFAULT_LOG)
    parser.add_argument("--bind", default="127.0.0.1")
    args = parser.parse_args()

    global TRACKER
    TRACKER = Tracker(args.log)
    # Prime with existing log content on startup.
    TRACKER.poll()

    os.makedirs(os.path.dirname(args.log), exist_ok=True)
    server = ThreadingHTTPServer((args.bind, args.port), Handler)
    print(f"[track] dashboard on http://{args.bind}:{args.port} reading {args.log}")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass


if __name__ == "__main__":
    main()
</file>

<file path="authentik-compose.yaml">
# Authentik — the single identity provider for every app (SSO).
#
# Run with:  docker compose -f authentik-compose.yaml up -d
# Manager UI:  https://home.palashkantikundu.in/sso/if/admin/

version: '3.8'

services:
  authentik-postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    volumes:
      - pg_data:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: ${POSTGRES_DB:-authentik}
      POSTGRES_USER: ${POSTGRES_USER:-authentik}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
      interval: 5s
      timeout: 5s
      retries: 10

  authentik-redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: --save 60 1 --loglevel warning
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
      interval: 5s
      timeout: 3s
      retries: 10

  authentik-server:
    image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-2025.2.1}
    restart: unless-stopped
    command: server
    ports:
      - "127.0.0.1:9008:9000"
      - "127.0.0.1:9443:9443"
    environment:
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?set AUTHENTIK_SECRET_KEY in .env}
      AUTHENTIK_TOKEN: ${AUTHENTIK_TOKEN:?set AUTHENTIK_TOKEN in .env}
      AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:-}
      AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN:-}
      AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL:-}
      # FIXED: Points to external HTTPS domain so SSO redirects work properly
      AUTHENTIK_URL: ${AUTHENTIK_BASE_URL:-https://home.palashkantikundu.in/sso}
      AUTHENTIK_PORT: "9000"
      AUTHENTIK_REDIS__HOST: authentik-redis
      AUTHENTIK_POSTGRESQL__HOST: authentik-postgres
      AUTHENTIK_POSTGRESQL__NAME: ${POSTGRES_DB:-authentik}
      AUTHENTIK_POSTGRESQL__USER: ${POSTGRES_USER:-authentik}
      AUTHENTIK_POSTGRESQL__PASSWORD: ${POSTGRES_PASSWORD}
      AUTHENTIK_CORS__ALLOWED_ORIGINS: '*'
      AUTHENTIK_WEB__PATH: /sso/
      AUTHENTIK_COOKIE_DOMAIN: ${AUTHENTIK_COOKIE_DOMAIN:-home.palashkantikundu.in}
      AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS: "127.0.0.1/32,10.66.66.0/8,172.16.0.0/12,192.168.0.0/16"
    volumes:
      - media:/media
      - custom-templates:/templates
    depends_on:
      authentik-postgres:
        condition: service_healthy
      authentik-redis:
        condition: service_healthy

  authentik-worker:
    image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-2025.2.1}
    restart: unless-stopped
    command: worker
    environment:
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?set AUTHENTIK_SECRET_KEY in .env}
      AUTHENTIK_TOKEN: ${AUTHENTIK_TOKEN:?set AUTHENTIK_TOKEN in .env}
      AUTHENTIK_URL: ${AUTHENTIK_BASE_URL:-https://home.palashkantikundu.in/sso}
      AUTHENTIK_REDIS__HOST: authentik-redis
      AUTHENTIK_POSTGRESQL__HOST: authentik-postgres
      AUTHENTIK_POSTGRESQL__NAME: ${POSTGRES_DB:-authentik}
      AUTHENTIK_POSTGRESQL__USER: ${POSTGRES_USER:-authentik}
      AUTHENTIK_POSTGRESQL__PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - media:/media
      - custom-templates:/templates
      - /var/run/docker.sock:/var/run/docker.sock
    depends_on:
      authentik-postgres:
        condition: service_healthy
      authentik-redis:
        condition: service_healthy
  
  authentik-outpost:
    image: ghcr.io/goauthentik/proxy:2025.2.1
    restart: unless-stopped
    ports:
      - "127.0.0.1:9010:9000"
    environment:
      AUTHENTIK_HOST: "http://authentik-server:9000/sso/"
      AUTHENTIK_HOST_BROWSER: "https://home.palashkantikundu.in/sso/"
      AUTHENTIK_INSECURE: "true"
      AUTHENTIK_TOKEN: "${AUTHENTIK_OUTPOST_TOKEN:?set in .env}"
    depends_on:
      authentik-server:
        condition: service_started

volumes:
  pg_data:
  redis_data:
  media:
  custom-templates:
</file>

<file path="prompts/moderator.txt">
You are the Story Moderator. You review finished, edited stories and give a final verdict: GREEN (approved for publishing) or RED (rejected).
You never delete or edit anything. You only categorize the story and briefly explain your verdict.

## Task context
- Genre: %genre%
- Declared mediums: %mediums%
- Declared language(s): %language%
- Task details: %details%

## Named characters (immutable)
%cast%

The characters above were decided and named before writing. The story text and
every image may only depict these named characters. A RED verdict is warranted
if any character that is not in this list appears in the story text or in any
image.

## Genre-specific checklist
Give a RED verdict if ANY item below fails. Only give GREEN if every item on this list passes.

%checklist%

## CRITICAL
You are moderator. You are not allowed to make any change in the generated content.

## Universal checks (apply regardless of genre)
- All declared mediums are actually present (e.g. if "image" is declared, at least one image must be embedded in the story).
- The story is written in the declared language(s), not translated or mixed.
- No "<!-- EDITOR FLAG: ... -->" comment is present anywhere — that means the  editor could not resolve a problem, which is an automatic RED.
- The names "Kaya", "Kolpo", "কায়া", "কল্প", "काया", "कल्प" do not appear  anywhere in the story text.
- The story actually reaches a real ending — no unfinished sentence, no  dangling setup, no abrupt cutoff mid-scene.

Give your verdict using exactly these two lines:
VERDICT: GREEN
REASONS: <short reasons, citing the specific checklist item(s) that failed if RED>

## Evaluation Principle
Exercise reasonable editorial judgment. Do not flag creative artistic choices, stylistic fragments, or open endings as failures unless they represent genuine errors, broken text, or incomplete content. If the work fulfills the core prompt and respects all hard constraints, default to GREEN.
</file>

<file path="scripts/gcp_heartbeat_server.py">
#!/usr/bin/env python3
"""Heartbeat receiver + split-horizon DNS for the GCP VM.

One process, two jobs:

1. Heartbeat receiver (HTTP, default :9863)
   The homeserver's ConnectionManager (server/features/monitoring.py) POSTs
   its current addresses every 10 seconds over the WireGuard tunnel:

       {"ipv6":        "2405:201:...",   # stable global IPv6
        "public_ipv4": "49.37...",       # WAN IPv4 (NAT'd, via ipify)
        "wifi_ipv4":   "192.168.29.x"}   # LAN IPv4 on the WiFi interface

   GET /status returns the last payload as JSON.

2. Split-horizon DNS (UDP+TCP :53)
   Answers for home.palashkantikundu.in depend on WHO asks:
     - query source IP == homeserver's latest WAN IP(s)  ->  LAN IPv4 (+AAAA)
     - everyone else                                     ->  this VM's public IP
   so same-network clients connect directly over the LAN while remote clients
   go through the nginx/WireGuard tunnel. Every other name is forwarded to
   upstream resolvers — but only for known homeserver IPs (no open resolver).

Run on the VM (port 53 needs root or CAP_NET_BIND_SERVICE):

    sudo pip3 install dnslib
    sudo python3 gcp_heartbeat_server.py [--bind 10.66.66.1] [--port 9863] \
        [--dns-bind 0.0.0.0] [--dns-port 53] [--gcp-ip 35.212.x.x]

The VM's public IP is auto-detected from the GCP metadata server unless
--gcp-ip is given. Open udp/tcp 53 in the GCP firewall.
"""

import argparse
import ipaddress
import json
import os
import struct
import threading
import urllib.request
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

from dnslib import A, AAAA, QTYPE, RCODE, RR, SOA, DNSRecord
from dnslib.server import DNSServer, BaseResolver

ZONE = "home.palashkantikundu.in"
UPSTREAMS = ["1.1.1.1", "8.8.8.8"]
ZONE_TTL = 30          # short TTL so IP changes propagate fast
WG_PEER_IP = "10.66.66.3"

_lock = threading.Lock()
_latest = {}

# Recently seen WAN IPs of the homeserver (Jio rotates them).
_recent_public_ips = {}          # ip -> last seen timestamp
_RECENT_TTL = 48 * 3600
_RECENT_MAX = 10


def _log(message, log_file=None):
    line = f"{datetime.now().isoformat()} {message}"
    print(line, flush=True)
    if log_file:
        with open(log_file, "a") as fh:
            fh.write(line + "\n")


def _valid_ip(value, version):
    try:
        return str(ipaddress.ip_address(value)) if value else None
    except ValueError:
        return None


def _remember_public_ip(ip, now):
    _recent_public_ips[ip] = now
    stale = [k for k, ts in _recent_public_ips.items() if now - ts > _RECENT_TTL]
    for k in stale:
        del _recent_public_ips[k]
    while len(_recent_public_ips) > _RECENT_MAX:
        del _recent_public_ips[min(_recent_public_ips, key=lambda k: _recent_public_ips[k])]


# ---------------------------------------------------------------------------
# DNS
# ---------------------------------------------------------------------------

def _detect_gcp_ip():
    """Public IP of this VM via the GCP metadata server."""
    try:
        req = urllib.request.Request(
            "http://metadata.google.internal/computeMetadata/v1/instance/"
            "network-interfaces/0/access-configs/0/external-ip",
            headers={"Metadata-Flavor": "Google"},
        )
        return urllib.request.urlopen(req, timeout=2).read().decode().strip()
    except Exception:
        return None


def _parse_ecs(data):
    """Parse one ECS option payload -> ip_network, or None."""
    if len(data) < 4:
        return None
    family, src_bits, _scope = struct.unpack("!HBB", data[:4])
    nbytes = (src_bits + 7) // 8
    alen = 4 if family == 1 else 16
    try:
        # RFC 7871: address bytes are truncated to src_bits — pad back out to
        # the full address length before parsing.
        raw = data[4:4 + nbytes].ljust(alen, b"\x00")
        if family == 1:
            return ipaddress.ip_network(
                f"{ipaddress.IPv4Address(raw)}/{src_bits}", strict=False)
        if family == 2:
            return ipaddress.ip_network(
                f"{ipaddress.IPv6Address(raw)}/{src_bits}", strict=False)
    except ValueError:
        pass
    return None


def _ecs_networks(request):
    """Client subnets advertised via EDNS Client Subnet (option 8).

    When this server is queried through third-party resolvers (Google,
    AdGuard, ...) the UDP source is the resolver, not the end client — the
    real client network arrives as an ECS option instead.
    """
    nets = []
    for ar in getattr(request, "ar", []) or []:
        if getattr(ar, "rtype", None) != 41:          # OPT RR only
            continue
        rd = getattr(ar, "rdata", None)
        options = rd if isinstance(rd, (list, tuple)) else ()
        for opt in options:                           # parsed EDNSOption objects
            if getattr(opt, "code", None) == 8:
                net = _parse_ecs(opt.data)
                if net:
                    nets.append(net)
        if isinstance(rd, (bytes, bytearray)):        # unparsed TLV blob
            i = 0
            while i + 4 <= len(rd):
                code, dlen = struct.unpack("!HH", rd[i:i + 4])
                if code == 8:
                    net = _parse_ecs(rd[i + 4:i + 4 + dlen])
                    if net:
                        nets.append(net)
                i += 4 + dlen
    return nets


class HomeResolver(BaseResolver):
    """Zone answers driven by heartbeat state; everything else forwarded."""

    def __init__(self, gcp_ip):
        self.gcp_ip = gcp_ip

    def _snapshot(self):
        with _lock:
            return (
                set(_recent_public_ips),
                _latest.get("wifi_ipv4"),
                _latest.get("ipv6"),
            )

    def _soa(self, reply):
        reply.add_auth(RR(
            ZONE, QTYPE.SOA,
            rdata=SOA(f"ns.{ZONE}", f"hostmaster.{ZONE}", (2024010101, 300, 60, 600, 30)),
            ttl=ZONE_TTL,
        ))

    def _is_local_client(self, client, request):
        """True when the query originates from the homeserver's own network.

        Matches either the UDP source IP directly (clients using this server
        as their resolver) or an EDNS Client Subnet covering one of the
        homeserver's recent WAN IPs (queries relayed through public
        resolvers once this server is authoritative via GoDaddy NS
        delegation).
        """
        local_ips, _, _ = self._snapshot()
        if client in local_ips:
            return True
        for net in _ecs_networks(request):
            for rip in local_ips:
                if ipaddress.ip_address(rip) in net:
                    return True
        return False

    def resolve(self, request, handler):
        client = handler.client_address[0]
        reply = request.reply()
        name = str(request.q.qname).rstrip(".").lower()
        qtype = QTYPE[request.q.qtype]

        _local_ips, wifi_ip, server_v6 = self._snapshot()
        is_local_client = self._is_local_client(client, request)

        if name == ZONE:
            if qtype == "A":
                target = wifi_ip if (is_local_client and wifi_ip) else self.gcp_ip
                if target:
                    reply.add_answer(RR(request.q.qname, QTYPE.A,
                                        rdata=A(target), ttl=ZONE_TTL))
                else:
                    self._soa(reply)
            elif qtype == "AAAA":
                # Always hand out the home server's global IPv6 — local or remote,
                # it's directly reachable either way.
                if server_v6:
                    reply.add_answer(RR(request.q.qname, QTYPE.AAAA,
                                        rdata=AAAA(server_v6), ttl=ZONE_TTL))
                else:
                    self._soa(reply)
            else:
                self._soa(reply)
            return reply

        # Recursion only for the homeserver itself (no open resolver).
        if not (is_local_client or client == WG_PEER_IP):
            reply.header.rcode = RCODE.REFUSED
            return reply

        try:
            return DNSRecord.parse(request.send(self._upstream(), 53, timeout=3))
        except Exception:
            reply.header.rcode = RCODE.SERVFAIL
            return reply

    @staticmethod
    def _upstream():
        return UPSTREAMS[0]


# ---------------------------------------------------------------------------
# Heartbeat HTTP receiver
# ---------------------------------------------------------------------------

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/heartbeat":
            self.send_error(404)
            return
        try:
            length = int(self.headers.get("Content-Length", 0))
            data = json.loads(self.rfile.read(length) or b"{}")
        except (ValueError, json.JSONDecodeError):
            self.send_error(400, "invalid JSON")
            return

        record = {
            "ipv6": _valid_ip(data.get("ipv6"), 6),
            "public_ipv4": _valid_ip(data.get("public_ipv4"), 4),
            "wifi_ipv4": _valid_ip(data.get("wifi_ipv4"), 4),
            "remote_addr": self.client_address[0],
            "received_at": datetime.now(timezone.utc).isoformat(),
        }
        with _lock:
            _latest.clear()
            _latest.update(record)

        if record["public_ipv4"]:
            now = datetime.now(timezone.utc).timestamp()
            with _lock:
                _remember_public_ip(record["public_ipv4"], now)

        _log(f"heartbeat from {record['remote_addr']}: "
             f"v6={record['ipv6']} pub_v4={record['public_ipv4']} wifi_v4={record['wifi_ipv4']}",
             self.server.log_file)
        self.send_response(200)
        self.end_headers()

    def do_GET(self):
        if self.path != "/status":
            self.send_error(404)
            return
        with _lock:
            body = json.dumps(_latest).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt, *args):
        pass  # request lines are logged by _log() instead


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--bind", default="10.66.66.1",
                        help="heartbeat listener address (default: WireGuard IP)")
    parser.add_argument("--port", type=int, default=9863)
    parser.add_argument("--log", default=os.path.expanduser("heartbeat.log"),
                        help="append received heartbeats to this file")
    parser.add_argument("--dns-bind", default="0.0.0.0",
                        help="DNS listener address (default: all interfaces)")
    parser.add_argument("--dns-port", type=int, default=53,
                        help="DNS port, 0 disables the DNS server")
    parser.add_argument("--gcp-ip", default=None,
                        help="this VM's public IPv4 (default: auto-detect)")
    args = parser.parse_args()

    gcp_ip = args.gcp_ip or _detect_gcp_ip()

    if args.dns_port:
        resolver = HomeResolver(gcp_ip)
        udp = DNSServer(resolver, port=args.dns_port, address=args.dns_bind)
        tcp = DNSServer(resolver, port=args.dns_port, address=args.dns_bind, tcp=True)
        udp.start_thread()
        tcp.start_thread()
        if not udp.isAlive() or not tcp.isAlive():
            raise SystemExit(f"could not bind DNS on {args.dns_bind}:{args.dns_port}")
        print(f"DNS listening on {args.dns_bind}:{args.dns_port} "
              f"(zone={ZONE}, tunnel_ip={gcp_ip})", flush=True)

    httpd = ThreadingHTTPServer((args.bind, args.port), Handler)
    httpd.log_file = args.log
    print(f"Heartbeat receiver listening on http://{args.bind}:{args.port}", flush=True)
    try:
        httpd.serve_forever()
    finally:
        if args.dns_port:
            udp.stop()
            tcp.stop()


if __name__ == "__main__":
    main()
</file>

<file path="server/features/critic.py">
"""Post-generation self-verification ("critic") pass for research answers.

After the LLM emits its final research answer, ``run_verification`` extracts the
structured ``(Author, Venue, Year) [url]`` inline citations, re-fetches each
source and asks a fresh LLM call ("the critic") to check that (1) the source
exists, (2) the claimed author/venue/year metadata matches the real page, and
(3) the specific claim is actually supported by the source text. The resulting
verdict drives a small, local edit of just that sentence (never a full report
rewrite) and a transparent verification trail is appended to the answer.

The pass is always-on for research tasks (no separate toggle) and bounded only
per-citation: at most ``VERIFY_RETRIES`` extra search/fetch attempts per
citation, so a pathological report can never loop forever while keeping the
"no overall cap" behaviour.
"""

import json
import re
import time

import requests

from server.features.state import M

_VERIFY_SYSTEM = (
    "You are a strict citation fact-checker for a research assistant. Given a "
    "claimed citation (URL plus author/venue/year metadata plus one specific "
    "claim) and the fetched text of the cited web page, determine: "
    "(1) whether the page exists and its topic matches the citation, "
    "(2) whether the cited author, venue and year actually appear on the page, "
    "(3) whether the exact claim is supported by the page text. "
    'Reply with ONLY a JSON object of the form: '
    '{"exists": bool, "url_ok": bool, "author_ok": bool, "venue_ok": bool, '
    '"year_ok": bool, "claim_support": "supports"|"partial"|"unsupported"|"absent", '
    '"corrected_meta": string|null, "reason": string}. '
    '"corrected_meta" must be a comma-separated "Author, Venue, Year" string when '
    'the metadata is wrong AND the page itself reveals the correct values, '
    'otherwise null. "year_ok"/"author_ok"/"venue_ok" must be false only when '
    'the page text contradicts the claim, not when the value is merely absent.'
)

_META_RE = re.compile(
    r"(?P<meta>[\[(][^)\]\[(\n]{0,180}[\]\)])\s*\[(?P<url>https?://[^\s\]<>']+)\]"
)
# A citation whose URL slot is empty, e.g. `[ScienceDirect Review] []` or the
# markdown link form `[Some Review]()`. These are fabricated by construction —
# there is nothing to verify.
_EMPTY_CITE_RE = re.compile(r"(?P<meta>[\[(][^)\]\[(\n]{0,180}[\]\)])\s*\[\s*\]")
_PLAIN_URL_RE = re.compile(r"(?<!\w)(https?://[^\s\]<>()]+)")
_CODE_FENCE_RE = re.compile(r"```.*?```", re.DOTALL)

_TAGLINE = "\n\n<details>\n<summary>Source verification</summary>"


def _critic_completion(system, user, mode="gpu", max_tokens=600):
    """Secondary, non-streamed, low-temperature LLM call. Retries once and
    never raises — returns None only when the model itself is unreachable."""
    payload = {
        "model": M.server_model_id(mode),
        "messages": [
            {"role": "system", "content": system},
            {"role": "user", "content": user},
        ],
        "max_tokens": max_tokens,
        "temperature": 0.2,
        "stream": False,
    }
    last_err = None
    for attempt in range(2):
        try:
            M.mark_slot_kv_dirty(mode)
            r = requests.post(M.server_url(mode), json=payload, timeout=120)
            r.raise_for_status()
            content = r.json()["choices"][0]["message"].get("content")
            if content:
                return content
            last_err = "empty content in response"
        except Exception as e:
            last_err = str(e)
        print(f"[critic] LLM call failed (mode={mode}, attempt {attempt + 1}/2): {last_err}")
        time.sleep(1.0)
    return None


def _parse_verdict(text):
    """Best-effort JSON parse of the critic's reply. Returns None on failure."""
    if not text:
        return None
    cleaned = re.sub(r"```(?:json)?", "", text).strip()
    start = cleaned.find("{")
    end = cleaned.rfind("}")
    if start < 0 or end <= start:
        return None
    try:
        return json.loads(cleaned[start:end + 1])
    except (ValueError, TypeError):
        return None


def extract_citations(answer):
    """Return a list of citation dicts found in the answer.

    Each dict is ``{idx, start, end, url, meta, prepared}`` where ``idx`` is the
    paragraph index in ``answer`` split on blank lines, ``start``/``end`` are
    offsets inside that paragraph, and ``meta`` is the "(Author, Venue, Year)"
    text (or None for a bare URL). Duplicate URLs are ignored.
    """
    citations = []
    seen = set()
    paras = re.split(r"\s*\n\s*\n\s*", answer or "")
    for idx, para in enumerate(paras):
        stripped = _CODE_FENCE_RE.sub("", para)
        structured = []
        for m in _EMPTY_CITE_RE.finditer(stripped):
            # Empty-URL citations are individually meaningful (each must be
            # stripped and flagged), so no URL-based dedupe applies here.
            citations.append({
                "idx": idx,
                "start": m.start(),
                "end": m.end(),
                "url": "",
                "meta": m.group("meta").strip(),
            })
        for m in _META_RE.finditer(stripped):
            url = m.group("url").rstrip(".,;:]")
            if url in seen:
                continue
            seen.add(url)
            item = {
                "idx": idx,
                "start": m.start(),
                "end": m.end(),
                "url": url,
                "meta": m.group("meta").strip(),
            }
            citations.append(item)
            structured.append(url.rstrip(".,;:]"))
        for m in _PLAIN_URL_RE.finditer(stripped):
            url = m.group(0).rstrip(".,;:]")
            if url in seen or url in structured:
                continue
            seen.add(url)
            citations.append({
                "idx": idx,
                "start": m.start(),
                "end": m.end(),
                "url": url,
                "meta": None,
            })
    return citations


def _norm_url(url):
    """Normalize a URL for equality checks: lowercase host, drop fragment and
    trailing slash, keep path and query."""
    try:
        from urllib.parse import urlsplit

        p = urlsplit(url or "")
        path = p.path.rstrip("/") or "/"
        parts = [p.netloc.lower() or url, path]
        if p.query:
            parts.append(f"?{p.query}")
        return "".join(parts)
    except Exception:
        return (url or "").rstrip("/")


def _retrieved_urls(task_id):
    """All URLs the research agent actually opened or saw in search results.

    Rebuilt from the task's ``_search_details`` (every ``web_search`` result
    URL plus every ``fetch_page`` URL and any link inside fetched content). A
    citation that is NOT in here was never grounded by the agent's own tools.
    """
    urls = set()
    with M._data_lock:
        details = list(M.tasks.get(task_id, {}).get("_search_details", []))
    for entry in details:
        if not isinstance(entry, dict):
            continue
        if entry.get("tool") == "fetch_page":
            u = entry.get("url", "")
            if u:
                urls.add(_norm_url(u))
            for m in _PLAIN_URL_RE.finditer(entry.get("content", "") or ""):
                urls.add(_norm_url(m.group(0).rstrip(".,;:]")))
            continue
        for r in entry.get("results", []) or []:
            if isinstance(r, dict):
                u = r.get("url") or r.get("link") or ""
                if u:
                    urls.add(_norm_url(u))
    return urls


def _citation_exists(url):
    """Existence probe for a source the agent never retrieved: re-search the
    URL itself and require a same-URL hit. Used before ANY invention verdict."""
    try:
        res = json.loads(M.web_search(url))
    except Exception as e:
        print(f"[critic] existence probe failed for {url}: {e}")
        return False
    target = _norm_url(url)
    for r in res.get("results", []) or []:
        if isinstance(r, dict):
            for u in (r.get("url"), r.get("link")):
                if u and _norm_url(u) == target:
                    return True
    return False


def _fetch_source(url, max_chars=6000):
    """Fetch a single source page; never raises. ``ok=False`` on any failure."""
    try:
        page = json.loads(M.fetch_page(url, max_chars=max_chars, chunk=1))
    except Exception as e:
        return {"ok": False, "url": url, "error": f"fetch failed: {e}"}
    if page.get("error"):
        return {"ok": False, "url": url, "error": page["error"]}
    return {
        "ok": True,
        "url": url,
        "final_url": page.get("url", url),
        "title": page.get("title", ""),
        "content": page.get("content", "") or "",
    }


def _verify_source(cit, src, mode):
    """Single critic call for one citation. Returns (verdict, failed)."""
    if not (src and src.get("ok")):
        return {
            "exists": False,
            "url_ok": False,
            "claim_support": "absent",
            "corrected_meta": None,
            "reason": (src or {}).get("error") or "source could not be fetched",
        }, False
    body = src.get("content", "") or ""
    if not body.strip():
        return {
            "exists": True,
            "url_ok": True,
            "claim_support": "absent",
            "corrected_meta": None,
            "reason": "page content unreachable (likely JS-only or blocked)",
        }, False
    user = (
        f"CLAIMED CITATION:\n"
        f"URL: {cit['url']}\n"
        f"Metadata: {cit.get('meta') or '(none given)'}\n\n"
        f"CLAIM CONTEXT:\n{cit.get('prepared') or cit.get('para') or ''}\n\n"
        f"FETCHED SOURCE (title: {src.get('title', '')}):\n"
        f"{body[:M.VERIFY_FETCH_CHARS]}"
    )
    text = _critic_completion(_VERIFY_SYSTEM, user, mode)
    if text is None:
        return None, True
    verdict = _parse_verdict(text)
    if verdict is None:
        return None, True
    return verdict, False


def classify(verdict, src):
    """Map a critic verdict (+ fetch info) to an action token."""
    if not verdict:
        return "UNCHECKED"
    if verdict.get("exists") is False or verdict.get("url_ok") is False:
        return "UNVERIFIABLE"
    if not (src and src.get("ok")) or not (src.get("content") or "").strip():
        return "AMBIGUOUS"
    support = verdict.get("claim_support", "absent")
    if support in ("unsupported", "absent"):
        return "UNVERIFIABLE"
    if any(verdict.get(f) is False for f in ("author_ok", "venue_ok", "year_ok")):
        return "METADATA_FIX"
    if support == "partial":
        return "AMBIGUOUS"
    return "KEEP"


def _search_and_fetch(url, meta):
    """Targeted re-search for a corrected source; prefers the same host."""
    host = ""
    try:
        from urllib.parse import urlparse

        host = urlparse(url).netloc
    except Exception:
        pass
    query = f"{meta or url} {url}".strip()
    try:
        res = json.loads(M.web_search(query))
    except Exception:
        res = {}
    results = res.get("results", [])
    for r in results:
        u = r.get("url", "")
        r_host = ""
        try:
            from urllib.parse import urlparse

            r_host = urlparse(u).netloc
        except Exception:
            pass
        if (not host or r_host == host) and r.get("full_content"):
            return {"ok": True, "url": u, "title": r.get("page_title", ""), "content": r.get("full_content", "")}
    for r in results:
        if r.get("full_content"):
            return {"ok": True, "url": r.get("url", ""), "title": r.get("page_title", ""), "content": r.get("full_content", "")}
    return {"ok": False, "url": url}


def _verify_one(cit, mode):
    """Full per-citation pipeline. Returns (action, note, replace, verdict)."""
    src = _fetch_source(cit["url"])
    verdict, failed = _verify_source(cit, src, mode)
    if failed:
        return ("UNCHECKED",
                "critic unavailable — not verified",
                None, None)
    action = classify(verdict, src)

    if action == "METADATA_FIX" and not (verdict or {}).get("corrected_meta"):
        best = None
        for _ in range(max(1, M.VERIFY_RETRIES)):
            src2 = _search_and_fetch(cit["url"], cit.get("meta") or "")
            if not src2.get("ok"):
                continue
            v2, f2 = _verify_source(cit, src2, mode)
            if f2 or not v2:
                continue
            if v2.get("corrected_meta"):
                best = v2
                src = src2
                break
        if best:
            verdict = best
        else:
            verdict = {**verdict, "corrected_meta": None}

    if action == "UNVERIFIABLE" and verdict and not (src and src.get("ok")):
        # The page could not be fetched directly (403/404/timeout). Before
        # stripping, run targeted re-searches so a real but blocked/retired
        # source gets a second chance (e.g. via search snippets or mirrors).
        for _ in range(max(1, M.VERIFY_RETRIES)):
            src2 = _search_and_fetch(cit["url"], cit.get("meta") or "")
            if not src2.get("ok"):
                continue
            v2, f2 = _verify_source(cit, src2, mode)
            if not f2 and v2 and v2.get("exists"):
                verdict = v2
                src = src2
                action = classify(v2, src2)
                break

    if action == "METADATA_FIX":
        corrected = (verdict or {}).get("corrected_meta")
        claimed_meta = cit.get("meta")
        if corrected and claimed_meta:
            replace = f"({corrected}) [{cit['url']}]"
            note = f"metadata corrected to ({corrected})"
        elif claimed_meta:
            replace = f"(Author, Venue, uncertain) [{cit['url']}]"
            note = "metadata could not be confirmed — marked uncertain"
        else:
            replace = None
            note = "bare source URL without metadata — could not confirm metadata"
    elif action == "UNVERIFIABLE":
        replace = ""
        note = (verdict or {}).get("reason") or "source could not be confirmed — citation removed"
    elif action == "AMBIGUOUS":
        replace = None
        note = "claim only partially supported, or sources conflict — review"
        _maybe = (verdict or {}).get("corrected_meta")
        if _maybe:
            note = f"metadata uncertain ({_maybe}) — review"
    else:  # KEEP / UNCHECKED
        replace = None
        note = "verified"
    return action, note, replace, verdict


def _build_verification_block(verdicts):
    if not verdicts:
        return ""
    marks = {
        "KEEP": "✓",
        "METADATA_FIX": "△",
        "UNVERIFIABLE": "✗",
        "AMBIGUOUS": "⚠",
        "UNCHECKED": "∅",
    }
    lines = [_TAGLINE]
    for v in verdicts:
        action = v.get("action", "KEEP")
        symbol = marks.get(action, "•")
        note = f" — {v['note']}" if v.get("note") else ""
        lines.append(f"{symbol} {v['url']}{note}")
    lines.append("</details>")
    return "\n".join(lines)


def run_verification(task_id, sid, answer, mode="gpu"):
    """Extract citations, verify each, patch the answer, return (final, verdicts).

    Research tasks only; non-research answers and citation-free answers pass
    through untouched. Never raises.
    """
    with M._data_lock:
        t = M.tasks.get(task_id) or {}
    verdicts = []
    if not answer or not t.get("research"):
        return answer, verdicts

    paras = re.split(r"\s*\n\s*\n\s*", answer)
    citations = extract_citations(answer)
    if not citations:
        return answer, verdicts

    # Deterministic anti-fabrication gate, computed ONCE per task: the set of
    # URLs the agent genuinely retrieved, and per-URL usage so a single source
    # backing many separate claims is surfaced. This runs before any critic
    # LLM call and is decisive even when the critic model is unavailable.
    retrieved = _retrieved_urls(task_id)
    from collections import Counter

    # Count every inline citation occurrence in the raw answer (not the
    # deduped citation list) so a single URL backing several claims is
    # surfaced, even when the parser dedupes the repeated citation.
    usage = Counter()
    for m in _META_RE.finditer(_CODE_FENCE_RE.sub("", answer or "")):
        usage[m.group("url").rstrip(".,;:]")] += 1
    per_cite_usage = {c["url"]: usage.get(c["url"], 0) for c in citations if c["url"]}
    max_cites = int(getattr(M, "VERIFY_MAX_CITES_PER_URL", 3))

    per_para = {}
    for cit in citations:
        para = paras[cit["idx"]] if cit["idx"] < len(paras) else ""
        cit["prepared"] = para
        pre_action = None
        pre_note = ""
        if not cit["url"]:
            pre_action, pre_note = "UNVERIFIABLE", (
                "citation has no URL to verify — cannot exist"
            )
        elif cit["url"] not in retrieved:
            if not _citation_exists(cit["url"]):
                pre_action, pre_note = "UNVERIFIABLE", (
                    "URL was never retrieved by research and no verification "
                    "search could find it — likely fabricated"
                )
        over = per_cite_usage.get(cit["url"], 0)
        over_note = (""
                     if over <= max_cites
                     else f" SAME SOURCE CITED {over} TIMES for {over} separate claims — verify each maps to it.")

        if pre_action is not None:
            action, note, replace, verdict = pre_action, pre_note.strip(), "", None
        else:
            action, note, replace, verdict = _verify_one(cit, mode)
        if over_note and action != "UNVERIFIABLE":
            note = (note or "") + over_note
        verdicts.append({
            "url": cit["url"],
            "meta": cit.get("meta"),
            "action": action,
            "note": note,
            "corrected_meta": (verdict or {}).get("corrected_meta") if verdict else None,
            "reason": (verdict or {}).get("reason") if verdict else None,
        })
        if replace is not None:
            per_para.setdefault(cit["idx"], []).append({
                "start": cit["start"],
                "end": cit["end"],
                "replace": replace,
            })

    if per_para:
        for idx, edits in per_para.items():
            para = paras[idx]
            parts = []
            pos = 0
            for e in sorted(edits, key=lambda x: x["start"]):
                parts.append(para[pos:e["start"]])
                parts.append(e["replace"])
                pos = e["end"]
            parts.append(para[pos:])
            paras[idx] = "".join(parts)
        answer = "\n\n".join(paras)

    block = _build_verification_block(verdicts)
    if block:
        answer = answer.rstrip() + block
    return answer, verdicts


def run_verification_worker(task_id, sid, answer, body, mode):
    """Thread-pool entry point called from the orchestration event loop.

    Guarantees the task always finalizes: the (possibly patched) answer on
    success, the original answer untouched on any failure.
    """
    started = time.time()
    try:
        final, verdicts = run_verification(task_id, sid, answer, mode)
        with M._data_lock:
            tt = M.tasks.get(task_id)
            if tt:
                tt["_verification"] = verdicts
                tt["_verification_duration"] = round(time.time() - started, 1)
        M._finalize_task(task_id, sid, final, body)
    except Exception as e:
        print(f"[critic] verification pass failed for task {task_id}: {e}")
        with M._data_lock:
            tt = M.tasks.get(task_id)
            if tt:
                tt["_verification"] = [{"url": "?", "action": "UNCHECKED",
                                        "note": f"verification pass failed: {e}"}]
        M._finalize_task(task_id, sid, answer, body)
</file>

<file path="server/auth.py">
"""Unified RBAC / SSO identity layer backed by Authentik.

This is the SINGLE identity provider for every app on the box. There is no
more ``users.json``; users, passwords and roles live in Authentik.

Two identity paths converge here:

1. Browser users — nginx runs ``auth_request`` against the Authentik proxy
   outpost and forwards the ``X-Authentik-*`` claim headers upstream. Backends
   trust those headers (only nginx can reach the app ports).

2. Machine agents (self-chat.py) — obtain an OIDC access token via Authentik's
   OAuth2 password grant and send it as ``Authorization: Bearer <jwt>``.
   Backends verify the JWT signature against Authentik's JWKS.

The resolved identity is always a dict::

    {
        "username": str,
        "email": str,
        "name": str,
        "groups": [str, ...],   # raw Authentik group names
        "role": "admin" | "premium" | "free",
        "uid": str,             # Authentik user UUID (unique id across renames)
    }
"""

import re
import threading
import time

import jwt
from jwt import PyJWK
import requests

from server.config import (
    AUTH_AGENTS_CLIENT_ID,
    AUTH_AGENTS_CLIENT_SECRET,
    AUTH_AGENTS_ISSUER,
    AUTH_AGENTS_JWKS_URL,
    AUTH_AGENTS_TOKEN_URL,
    AUTH_ROLE_GROUPS,
)

# Highest role wins when a user belongs to several groups.
_ROLE_LEVEL = {"free": 0, "premium": 1, "admin": 2}

_jwks_cache = None
_jwks_cache_at = 0.0
_jwks_lock = threading.Lock()
_JWKS_TTL = 300


def _first(seq):
    for item in seq or ():
        if item:
            return item
    return None


def role_from_groups(groups):
    """Map Authentik group names to the app role scale (free/premium/admin)."""
    best = "free"
    best_level = -1
    for g in groups or ():
        role = AUTH_ROLE_GROUPS.get(g.lower().strip())
        if role and _ROLE_LEVEL.get(role, -1) > best_level:
            best = role
            best_level = _ROLE_LEVEL[role]
    return best


def _split_groups(raw):
    if not raw:
        return []
    if isinstance(raw, (list, tuple, set)):
        return [str(g).strip() for g in raw if str(g).strip()]
    return [g.strip() for g in re.split(r"[|,\s]+", str(raw)) if g.strip()]


def identity_from_headers(headers):
    """Resolve identity from the nginx-injected X-Authentik-* claim headers.

    These headers are set by nginx's auth_request subrequest against the
    Authentik proxy outpost and are only present on nginx-fronted traffic.
    Returns None when absent (e.g. direct localhost calls or agents).
    """
    username = _first(
        [headers.get("X-Authentik-Username"), headers.get("X-Authentik-User")]
    )
    if not username:
        return None
    groups = _split_groups(
        _first(
            [
                headers.get("X-Authentik-Groups"),
                headers.get("X-Authentik-Group"),
            ]
        )
    )
    return {
        "username": username,
        "email": headers.get("X-Authentik-Email", ""),
        "name": headers.get("X-Authentik-Name", ""),
        "groups": groups,
        "role": role_from_groups(groups),
        "uid": headers.get("X-Authentik-UID", ""),
    }


def _fetch_jwks():
    """Return the Authentik JWKS key set (cached for _JWKS_TTL seconds)."""
    global _jwks_cache, _jwks_cache_at
    with _jwks_lock:
        now = time.time()
        if _jwks_cache is not None and now - _jwks_cache_at < _JWKS_TTL:
            return _jwks_cache
        if not AUTH_AGENTS_JWKS_URL:
            return []
        try:
            resp = requests.get(AUTH_AGENTS_JWKS_URL, timeout=5)
            resp.raise_for_status()
            _jwks_cache = resp.json().get("keys", [])
            _jwks_cache_at = now
        except (requests.RequestException, ValueError):
            _jwks_cache = _jwks_cache or []
            _jwks_cache_at = now
        return _jwks_cache


def identity_from_bearer(authorization):
    """Verify an ``Authorization: Bearer <jwt>`` token and resolve its identity.

    Verifies the token signature against Authentik's JWKS and enforces the
    issuer (the Authentik OIDC provider URL). Returns None for expired, bogus
    or missing tokens. Raises on transient network failures (so callers can
    treat those distinctly from a plain "no identity").
    """
    if not authorization or not authorization.lower().startswith("bearer "):
        return None
    token = authorization.split(" ", 1)[1].strip()
    if not token:
        return None
    keys = _fetch_jwks()
    if not keys:
        raise RuntimeError("Authentik JWKS unavailable — cannot verify access token")

    decoded = None
    for key in keys:
        try:
            crypto_key = PyJWK.from_dict(key).key
            decoded = jwt.decode(
                token,
                crypto_key,
                algorithms=[key.get("alg", "RS256")],
                issuer=AUTH_AGENTS_ISSUER,
                options={"verify_aud": False},
            )
            break
        except jwt.InvalidTokenError:
            continue
    if not decoded:
        return None

    username = _first(
        [
            decoded.get("preferred_username"),
            decoded.get("username"),
            decoded.get("email"),
            decoded.get("sub"),
        ]
    )
    groups = _split_groups(
        _first([decoded.get("groups"), decoded.get("ak_groups")])
    )
    return {
        "username": username,
        "email": decoded.get("email", ""),
        "name": decoded.get("name", ""),
        "groups": groups,
        "role": role_from_groups(groups),
        "uid": decoded.get("sub", ""),
    }


def get_identity(headers):
    """Resolve identity from request headers (browser path + agent path)."""
    identity = identity_from_headers(headers)
    if identity:
        return identity
    return identity_from_bearer(headers.get("Authorization", ""))


def get_current_user(headers):
    """Return just the authenticated username (or None)."""
    identity = get_identity(headers)
    return identity["username"] if identity else None


def required_role_level(role):
    """Level for the given role name (free=0, premium=1, admin=2)."""
    return _ROLE_LEVEL.get(role, 0)


def oidc_password_grant(username, password):
    """Exchange agent credentials for an Authentik OIDC access token.

    Used by self-chat.py so the automated agents authenticate through the same
    identity provider as humans. Returns the access token string. Raises on any
    failure so callers can surface a clear error.
    """
    if not AUTH_AGENTS_TOKEN_URL or not AUTH_AGENTS_CLIENT_ID:
        raise RuntimeError(
            "Authentik OIDC not configured (AUTH_AGENTS_TOKEN_URL/AUTH_AGENTS_CLIENT_ID)"
        )
    resp = requests.post(
        AUTH_AGENTS_TOKEN_URL,
        data={
            "grant_type": "password",
            "username": username,
            "password": password,
            "client_id": AUTH_AGENTS_CLIENT_ID,
            "client_secret": AUTH_AGENTS_CLIENT_SECRET,
            "scope": "openid profile email groups",
        },
        timeout=15,
    )
    resp.raise_for_status()
    data = resp.json()
    access_token = data.get("access_token")
    if not access_token:
        raise RuntimeError(f"Authentik password grant returned no access token: {data}")
    return access_token
</file>

<file path="server/read_file.py">
import io
import os
import re
import subprocess
import tempfile


def strip_html(text):
    text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL)
    text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL)
    text = re.sub(r"<[^>]+>", " ", text)
    text = re.sub(r"\s+", " ", text).strip()
    return text


def read_file_text(file_path):
    ext = os.path.splitext(file_path)[1].lower()
    with open(file_path, "rb") as f:
        raw = f.read()
    if ext == ".pdf":
        try:
            import fitz
            doc = fitz.open(stream=raw, filetype="pdf")
            lines = []
            for page in doc:
                lines.append(page.get_text())
            doc.close()
            text = "\n".join(lines)
            return strip_html(text)
        except ImportError:
            with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmpf:
                tmpf.write(raw)
                tmp = tmpf.name
            try:
                r = subprocess.run(
                    ["pdftotext", tmp, "-"], capture_output=True, text=True, timeout=30
                )
                return r.stdout
            finally:
                os.unlink(tmp)
    elif ext == ".docx":
        from docx import Document
        doc = Document(io.BytesIO(raw))
        return "\n".join(p.text for p in doc.paragraphs)
    elif ext == ".doc":
        with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmpf:
            tmpf.write(raw)
            tmp = tmpf.name
        try:
            r = subprocess.run(
                ["catdoc", tmp], capture_output=True, text=True, timeout=30
            )
            if r.returncode == 0:
                return r.stdout
            r = subprocess.run(
                ["antiword", tmp], capture_output=True, text=True, timeout=30
            )
            return r.stdout
        finally:
            os.unlink(tmp)
    elif ext in (".xls", ".xlsx"):
        from openpyxl import load_workbook
        wb = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
        rows = []
        for sheet in wb.worksheets:
            for row in sheet.iter_rows(values_only=True):
                rows.append("\t".join(str(c) if c is not None else "" for c in row))
        wb.close()
        return "\n".join(rows)
    else:
        # Plain text / code files (.py, .js, .json, .md, .txt, .csv, etc.)
        try:
            print("Reading code file(s)", raw)
            return raw.decode("utf-8")
        except UnicodeDecodeError:
            try:
                print("Trying Latin")
                return raw.decode("latin-1")
            except Exception:
                print("Exception")
                return ""
    return ""
</file>

<file path="setup.sh">
#!/usr/bin/env bash
set -euo pipefail

CHAT_DIR="$(cd "$(dirname "$0")" && pwd)"
LOCAL_AI_HOME="$HOME/local-ai"
FILES_DIR="$HOME/local-ai-files"

# Environment detection (host vs. container)
if [ "$(id -u)" -eq 0 ]; then SUDO=""; else SUDO="sudo"; fi
if [ -d /run/systemd/system ]; then SYSTEMD=1; else SYSTEMD=0; fi
if [ -f /.dockerenv ] || [ -f /run/.containerenv ]; then IN_CONTAINER=1; else IN_CONTAINER=0; fi

if [ "$IN_CONTAINER" -eq 1 ]; then
    echo "==> Detected: running inside a container (SearXNG / nginx / mDNS setup skipped)"
else
    echo "==> Detected: running on the host OS (SearXNG / nginx / mDNS setup enabled)"
fi

echo "==> Installing system packages..."
$SUDO apt update
$SUDO apt install -y \
    git python3 python3-venv python3-pip \
    build-essential cmake \
    nginx avahi-daemon \
    pdftotext poppler-utils catdoc antiword \
    curl docker.io docker-compose-v2

# Ensure Node.js (LTS) is installed (required for frontend build)
if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
    echo "==> Installing Node.js (18.x LTS)..."
    curl -fsSL https://deb.nodesource.com/setup_18.x | $SUDO -E bash -
    $SUDO apt install -y nodejs
fi

# GPU / CUDA toolchain (required to build llama.cpp with -DGGML_CUDA=ON)
#   - Host OS:        needs an NVIDIA driver + CUDA toolkit (nvcc)
#   - Dockerized:     needs nvidia-container-toolkit on the HOST + CUDA toolkit inside the container
if ! command -v nvidia-smi >/dev/null 2>&1; then
    if [ "$IN_CONTAINER" -eq 1 ]; then
        echo "ERROR: nvidia-smi not found inside the container." >&2
        echo "       Install nvidia-container-toolkit on the HOST and start the container with --gpus all." >&2
    else
        echo "ERROR: nvidia-smi not found. Install the NVIDIA driver on this host first." >&2
    fi
    exit 1
fi
if ! command -v nvcc >/dev/null 2>&1; then
    echo "==> nvcc not found, installing CUDA toolkit (large download)..."
    $SUDO apt install -y nvidia-cuda-toolkit
fi

echo "==> Starting avahi-daemon..."
if [ "$SYSTEMD" -eq 1 ]; then
    $SUDO systemctl enable --now avahi-daemon 2>/dev/null || true
fi

echo "==> Creating directory structure..."
mkdir -p "$FILES_DIR"/{ComfyUI/{input,output},my-models}

# ──────────────────────────────────────────────
# ComfyUI
# ──────────────────────────────────────────────
if [ ! -d "$LOCAL_AI_HOME/ComfyUI" ]; then
    echo "==> Cloning ComfyUI..."
    git clone https://github.com/comfyanonymous/ComfyUI.git "$LOCAL_AI_HOME/ComfyUI"
fi

if [ ! -d "$LOCAL_AI_HOME/ComfyUI/venv" ]; then
    echo "==> Setting up ComfyUI venv..."
    python3 -m venv "$LOCAL_AI_HOME/ComfyUI/venv"
    source "$LOCAL_AI_HOME/ComfyUI/venv/bin/activate"
    pip install -r "$LOCAL_AI_HOME/ComfyUI/requirements.txt"
    deactivate
fi

# ──────────────────────────────────────────────
# llama.cpp
# ──────────────────────────────────────────────
if [ ! -d "$LOCAL_AI_HOME/llama.cpp" ]; then
    echo "==> Cloning llama.cpp..."
    git clone https://github.com/ggml-org/llama.cpp.git "$LOCAL_AI_HOME/llama.cpp"
fi

if [ ! -f "$LOCAL_AI_HOME/llama.cpp/build/bin/llama-server" ]; then
    echo "==> Building llama.cpp..."
    cmake -S "$LOCAL_AI_HOME/llama.cpp" -B "$LOCAL_AI_HOME/llama.cpp/build" \
        -DGGML_CUDA=ON \
        -DCMAKE_BUILD_TYPE=Release
    cmake --build "$LOCAL_AI_HOME/llama.cpp/build" --config Release -j "$(nproc)"
fi

# ──────────────────────────────────────────────
# Chat frontend (this repo)
# ──────────────────────────────────────────────
echo "==> Setting up chat frontend..."
# Ensure we run npm from the chat directory
cd "$CHAT_DIR"

if [ ! -d "node_modules" ]; then
    npm install
fi
if [ ! -d "dist" ]; then
    npm run build
fi

# ──────────────────────────────────────────────
# Config files
# ──────────────────────────────────────────────
echo "==> Creating config files..."

if [ ! -f "$FILES_DIR/model.json" ]; then
    cat > "$FILES_DIR/model.json" << 'MODELEOF'
{
  "gpu": "gemma4-e2b",
  "cpu": "gemma4-e4b-q4"
}
MODELEOF
    echo "    $FILES_DIR/model.json (GPU model for the chat UI, CPU model for self-chat agents; edit if you download different models)"
fi

if [ ! -f "$FILES_DIR/models.json" ]; then
    cat > "$FILES_DIR/models.json" << 'JSONEOF'
{
  "z_image": {
    "unet": "z_image_turbo_bf16.safetensors",
    "clip1": "qwen_3_4b.safetensors",
    "vae": "ae.safetensors",
    "description": "Z-Image Turbo (512x512, 8 steps, fast aesthetic image generation)"
  }
}
JSONEOF
    echo "    $FILES_DIR/models.json (matches the downloaded z_image files below)"
fi

# Users are managed by Authentik (unified SSO) — users.json is gone. See
# authentik-compose.yaml + scripts/authentik_bootstrap.py to provision the
# identity provider and its users/groups. Context files are derived from each
# username at ~/local-ai-files/contexts/<user>.txt.

if [ ! -f "$FILES_DIR/sys_prompt.txt" ]; then
    cat > "$FILES_DIR/sys_prompt.txt" << 'PROMPTEOF'
You are a helpful AI assistant with the following capabilities:

- Web search: You can search the web for real-time information.
- Image generation: You can generate images using ComfyUI. Available styles: %model_list%
- Image editing: You can edit existing images.
- File extraction: You can read text from uploaded PDF, DOCX, XLSX files.

Current time: %current_time%
Current location: %current_location%

Always respond in a helpful, concise manner.
PROMPTEOF
    echo "    $FILES_DIR/sys_prompt.txt"
fi

mkdir -p "$FILES_DIR/contexts"

# ──────────────────────────────────────────────
# SearXNG (Docker)
# ──────────────────────────────────────────────

# Skip inside a container: SearXNG is provided as a sibling service (docker-compose)
if [ "$IN_CONTAINER" -eq 1 ]; then
    echo "==> Skipping SearXNG container (running inside a container; provide it via docker-compose)"

else
    # Choose docker invocation depending on permissions
    if docker info >/dev/null 2>&1; then
        DOCKER_CMD="docker"
    else
        DOCKER_CMD="sudo docker"
    fi

    if ! $DOCKER_CMD ps --format '{{.Names}}' 2>/dev/null | grep -q searxng; then
        echo "==> Starting SearXNG..."
        mkdir -p "$FILES_DIR/searxng"
        $DOCKER_CMD run -d --name searxng --restart unless-stopped \
            -p 127.0.0.1:8080:8080 \
            -v "$FILES_DIR/searxng:/etc/searxng:rw" \
            -e SEARXNG_BASE_URL="http://localhost:8080/" \
            searxng/searxng
        echo "    SearXNG starting on http://localhost:8080"
    fi
fi

# ──────────────────────────────────────────────
# mDNS / nginx
# ──────────────────────────────────────────────
echo "==> Setting up mDNS and nginx..."
if [ "$SYSTEMD" -eq 1 ]; then
    $SUDO hostnamectl set-hostname chat 2>/dev/null || true
fi

$SUDO tee /etc/nginx/sites-available/chat.local > /dev/null << 'NGINXEOF'
server {
    listen 80;
    server_name chat.local;

    location / {
        proxy_pass http://127.0.0.1:3001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}
NGINXEOF

if [ ! -L /etc/nginx/sites-enabled/chat.local ]; then
    $SUDO ln -s /etc/nginx/sites-available/chat.local /etc/nginx/sites-enabled/
fi
$SUDO rm -f /etc/nginx/sites-enabled/default
if $SUDO nginx -t; then
    if [ "$SYSTEMD" -eq 1 ]; then
        $SUDO systemctl restart nginx
    else
        $SUDO service nginx restart 2>/dev/null || true
    fi
fi

$SUDO tee /etc/avahi/services/chat.service > /dev/null << 'AVAHIEOF'
<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
<name>Chat AI</name>
<service>
    <type>_http._tcp</type>
    <port>80</port>
    <host-name>chat.local</host-name>
</service>
</service-group>
AVAHIEOF

if [ "$SYSTEMD" -eq 1 ]; then
    $SUDO systemctl restart avahi-daemon 2>/dev/null || true
else
    $SUDO service avahi-daemon restart 2>/dev/null || true
fi

# ──────────────────────────────────────────────
# UFW
# ──────────────────────────────────────────────
if command -v ufw &>/dev/null; then
    echo "==> Configuring UFW..."
    $SUDO ufw allow in on wlp2s0 2>/dev/null || true
    $SUDO ufw allow out on wlp2s0 2>/dev/null || true
fi

# ──────────────────────────────────────────────
# Done
# ──────────────────────────────────────────────
echo ""
echo "============================================"
echo "  Setup complete!"
echo ""
echo "  POST-PROCESSING (models are NOT downloaded by this script):"
echo "    Download whatever you need, then run the app. Nothing else to configure."
echo ""
echo "    1. LLM (chat) — your choice:"
echo "       Place a GGUF model into:  $FILES_DIR/my-models/"
echo "       GPU model (chat UI):      $(python3 -c "import json;print(json.load(open('$FILES_DIR/model.json'))['gpu'])" 2>/dev/null)"
echo "       CPU model (self-chat):    $(python3 -c "import json;print(json.load(open('$FILES_DIR/model.json'))['cpu'])" 2>/dev/null)"
echo "       (edit $FILES_DIR/model.json if you download different models)"
echo ""
echo "    2. Image model z_image — place these files:"
echo "       $LOCAL_AI_HOME/ComfyUI/models/diffusion_models/z_image_turbo_bf16.safetensors"
echo "       $LOCAL_AI_HOME/ComfyUI/models/text_encoders/qwen_3_4b.safetensors"
echo "       $LOCAL_AI_HOME/ComfyUI/models/vae/ae.safetensors"
echo ""
echo "    3. Users & roles: create them in Authentik (see authentik-compose.yaml"
echo "       + scripts/authentik_bootstrap.py). Context files auto-derive per user."
echo ""
echo "  Then just run:"
echo "    cd $CHAT_DIR && python chat-webui.py"
echo "    (it auto-starts llama-server and ComfyUI when needed)"
echo ""
echo "  Access at: http://chat.local  or  http://localhost:3001"
echo "============================================"
</file>

<file path="prompts/editor.txt">
You are the writing Editor. A neutral, careful editor who reviews and polishes generated write up. You read the content markdown, look at the images, decide what to improve, and return the full revised markdown. You preserve the original content, image references, language, and citations. Your soul aim is to maintain the established quality.

## Task context
- Genre: %genre%
- Declared mediums: %mediums%
- Declared language(s): %language%
- Task details: %details%

## Named characters (immutable)
%cast%

The characters above were decided and named before writing. This is the COMPLETE
cast: the story text and every image may only depict these named characters.
Never introduce, name, or depict any additional character (human, animal, or
creature) in your revision or in any image. If the revision contains a character
not listed here, remove it.

## Genre-specific checklist
Check the content against every item below. Where you can fix a failure yourself, fix it directly in your revision — do not just describe the problem, correct the markdown. If a failure cannot be fixed without content you don't have the power to create (e.g. an image was never generated and you cannot generate one), add a single HTML comment as the very first line of your revision, in this exact form, so the automated check and the moderator both see it:

<!-- EDITOR FLAG: <short description of the unresolved problem> -->

%checklist%

## Non-negotiable preservation rules
- Never remove or alter the header metadata block (Task prompt, Genre, For roles,
  Mediums, Language(s)).
- Never remove existing image references (`![...](...)`) unless the image file is
  genuinely broken or missing.
- Keep every image reference embedded INLINE within the story: each image must
  stay right after the paragraph of narrative text it illustrates (exactly where
  it appears in the input markdown). Never move images to the top or bottom of
  the document, and never stack them all together.
- Do not change the heading level of "## Citations & References" (keep the exact
  "##" prefix and the exact section name).
- Never add new sections or image or any new ideas to the existing work done by Kaya and Kolpo
- Never remove the "## Citations & References" section if the original has one.
- Never write the names "Kaya", "Kolpo", "কায়া", "কল্প", "काया", "कल्प" anywhere
  in the content text, dialogue, captions, or headings.
- Keep the content in the declared language(s). Do not translate it.
- Strip out any meta-commentary, planning chatter, turn discussions, or conversational setup (e.g., 'Sounds much better than wrestling with the laundry basket tonight', 'I vote for animals!'). Retain ONLY the final, continuous story narrative and structural headers.

### CRITICAL TASK COMPLETION RULES
- ONCE CONTENT IS EDITED, CHECK THE TASK LIST
- IF ANY TASK IN THE PENDING STATUS GOT COMPLETED IN THE CONTENT, MARK THAT `THEME` COMPLETE
</file>

<file path="docker-compose.yaml">
version: '3.8'

services:
  searxng:
    image: searxng/searxng:latest
    container_name: searxng
    restart: unless-stopped
    networks:
      - external-net
      - internal-net
    ports:
      - "8080:8080"
    volumes:
      - ./searxng/settings.yml:/etc/searxng/settings.yml:ro

  # 2. Nextcloud Database
  cloud-db:
    container_name: cloud-db
    image: mariadb:10.11
    restart: always
    command: --transaction-isolation=READ-COMMITTED --log-bin-trust-function-creators=1
    networks:
      - internal-net
    volumes:
      - db_data:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: your_root_password
      MYSQL_DATABASE: nextcloud
      MYSQL_USER: nextcloud
      MYSQL_PASSWORD: your_db_password

  # 3. Nextcloud Application
  cloud-app:
    container_name: cloud-app
    image: nextcloud
    restart: always
    # Pin the public hostname to the host so server-side OIDC calls (discovery,
    # logout) hit local nginx directly instead of round-tripping via GCP/DNS.
    extra_hosts:
      - "home.palashkantikundu.in:host-gateway"
    networks:
      - external-net
      - internal-net
    ports:
      - "8082:80"
    depends_on:
      - cloud-db
    volumes:
      - nextcloud_data:/var/www/html
      - /mnt/wwn-0x50014ee2173893e0-part1/BackUp-Copy-2:/mnt/my_backups:ro
    environment:
      MYSQL_HOST: cloud-db
      MYSQL_DATABASE: nextcloud
      MYSQL_USER: nextcloud
      MYSQL_PASSWORD: your_db_password
      # Dynamic Host & Subpath rules
      OVERWRITEPROTOCOL: "https"
      OVERWRITEWEBROOT: "/cloud"

networks:
  external-net:
    driver: bridge
  internal-net:
    driver: bridge

volumes:
  db_data:
  nextcloud_data:
</file>

<file path="package.json">
{
  "name": "local-ai",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "dompurify": "^3.4.12",
    "katex": "^0.17.0",
    "marked": "^15.0.7",
    "marked-katex-extension": "^5.1.10",
    "react": "^19.1.0",
    "react-dom": "^19.1.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.4.1",
    "vite": "^6.3.2"
  }
}
</file>

<file path="README.md">
# Local AI - LLM + Image Generation Setup

Self-hosted LLM + image generation stack on a single laptop (RTX 3050, 4 GB VRAM, 16 GB RAM).

## Requirements

- NVIDIA GPU with the driver working — check with `nvidia-smi`.
- **Host OS path:** CUDA toolkit (`nvcc`) needed to build llama.cpp. `setup.sh` installs
  it automatically if it's missing.
- **Dockerized path:** **NVIDIA Container Toolkit** on the *host* (for `--gpus`), plus
  CUDA toolkit *inside* the container (`setup.sh` installs it there too).

## Quick Start — Host OS

```bash
# 1. Clone and build
git clone <this-repo> ~/git/local-ai
cd ~/git/local-ai
bash setup.sh

# 2. Post-processing — download your models (setup.sh does NOT download them)
#    LLM (chat):   put a GGUF into ~/local-ai-files/my-models/
#                  model.json holds "gpu" (chat UI) and "cpu" (self-chat agents)
#                  model ids — edit it if you use other models
#    Image (z_image): copy these into ~/local-ai/ComfyUI/models/:
#      diffusion_models/z_image_turbo_bf16.safetensors
#      text_encoders/qwen_3_4b.safetensors
#      vae/ae.safetensors

# 3. Run — nothing else to configure
cd ~/git/local-ai && python chat-webui.py
```

Access at `http://chat.local` or `http://localhost:3001`.

Authentication is unified SSO via Authentik — see "Authentication (SSO)" below.

Self-chat agents (editor/moderator/registered agents) run on the CPU llama-server
(`http://localhost:8079`) by default so they never compete with interactive UI
users for VRAM. To run them on the interactive GPU server instead, set the
`SELF_CHAT_MODE` environment variable to `gpu`:

```bash
SELF_CHAT_MODE=gpu python chat-webui.py
```

You can also edit `SELF_CHAT_MODE` in `server/config.py`.

`chat-webui.py` auto-starts the two llama-servers on boot if they're down, and
starts ComfyUI on demand, so no manual service startup is required. If you prefer
to run the services manually:

```bash
# GPU llama-server — interactive chat UI users (VRAM-backed, 24K context)
~/local-ai/llama.cpp/build/bin/llama-server \
    --host 0.0.0.0 --port 8081 \
    --models-dir ~/local-ai-files/my-models/ \
    --jinja -ngl 99 -fa on --ctx-size 24576 \
    -ctk q8_0 -ctv q8_0 \
    --no-mmproj-offload

# CPU llama-server — automated self-chat agents (RAM-backed, concurrent)
~/local-ai/llama.cpp/build/bin/llama-server \
    --host 0.0.0.0 --port 8079 \
    --models-dir ~/local-ai-files/my-models/ \
    --jinja --n-gpu-layers 0 -fa off --ctx-size 32768 \
    -ctk q8_0 -nkvo \
    --reasoning-budget 4096 \
    --no-mmproj-offload --device none

cd ~/local-ai/ComfyUI && source venv/bin/activate && python main.py \
    --lowvram \
    --input-directory ~/local-ai-files/ComfyUI/input \
    --output-directory ~/local-ai-files/ComfyUI/output
```

## Quick Start — Dockerized (GPU)

The repo ships a `docker-compose.yaml` that runs the same stack inside an
`ubuntu:24.04` container with GPU passthrough, with SearXNG as a sibling service.

```bash
# 1. Start services (SearXNG + ai-container with GPU + shared dirs)
docker compose up -d

# 2. Enter the container and run setup
docker exec -it ai-container bash
cd /root/git/local-ai
bash setup.sh

# 3. Post-processing — download models into the shared host dirs
#    (same list as the Host OS path, but the image models land in
#     ~/local-ai/ComfyUI/models/ on the HOST, which is mounted into the container)

# 4. Run — inside the container
cd /root/git/local-ai && python chat-webui.py
```

Access at `http://localhost:3001` (published from the container). Notes:

- **Requires the NVIDIA Container Toolkit on the host**; the compose already passes
  `--gpus` to the container.
- `setup.sh` auto-detects the container: runs without `sudo`, skips systemd/mDNS/nginx
  (not available in a container), skips starting SearXNG (provided by compose), and
  installs the CUDA toolkit inside the container if `nvcc` is missing.
- The container needs internet during setup (`apt`, `git clone`, `npm`, CUDA toolkit);
  the compose attaches it to `external-net`.
- Config/data dirs (`~/local-ai-files`) are shared with the host, so models and
  sessions persist across container restarts.

## Authentication (SSO)

Authentication is unified **SSO via Authentik** — the single identity provider for
every app on the box. There is **no `users.json` and no per-app password database**;
users, passwords and roles live in Authentik.

**Two identity paths:**

1. **Browsers** — nginx runs an `auth_request` subrequest against the Authentik
   proxy outpost (`location /ak-auth-ai` in `local_cloud.sh`). If the SSO session is
   valid the outpost answers 200 and populates `X-Authentik-*` claim headers, which
   nginx forwards to the upstream apps. On 401 nginx sends the browser to the SSO
   portal (`@ak-sso-ai`). The SPA calls `/api/check-auth` on load to learn who the
   user is.
2. **Machine agents** (`self-chat.py`) — authenticate via Authentik's OAuth2 password
   grant and send the JWT as `Authorization: Bearer <token>`. Backends verify the
   signature against Authentik's JWKS (`server/auth.py` → `identity_from_bearer`).

Resolved identity is always a dict — `username`, `email`, `name`, `groups`, `role`
(`free`/`premium`/`admin` from the user's Authentik groups) and the Authentik `uid`
(`server/auth.py` → `get_identity`). Roles decide Story collection access and the
"overwrite user context" admin action.

**Enabling steps** (one-time):

1. Fill the `AUTHENTIK_*` / `POSTGRES_*` secrets in `.env` (see `authentik-compose.yaml`).
2. Start Authentik: `docker compose -f authentik-compose.yaml up -d`.
3. Open `https://<host>/sso/if/flow/initial-setup/` and create the admin account.
4. Provision groups/users, the `local-ai` OIDC provider and the proxy outpost:
   `python3 scripts/authentik_bootstrap.py`.
5. Deploy the proxy outpost (`ghcr.io/goauthentik/proxy`) with the outpost token the
   bootstrap script prints, on `127.0.0.1:9010` (nginx's `ak_outpost` upstream).
6. Ensure the apps are only reachable through the nginx front-end in `local_cloud.sh`
   (the `auth_request` gate on `/ai/`, `/api/`, `/stories/`, `/story/`), then reload
   nginx.

## Security & Deployment Notes

> **Intended scope: a private, trusted home deployment** — e.g. a household of a few
> users on a home LAN (this project targets ~2–4 concurrent users). The following
> limitations are **accepted risk** for that use case. This stack is **not** built for
> production, the public internet, or a shared LAN where many unknown users work —
> do **not** use it under those conditions.

> **Authentication is unified SSO.** Browser access requires an Authentik session
> (nginx `auth_request`), and per-user identity comes from the forwarded
> `X-Authentik-*` headers (see "Authentication (SSO)" below). The notes that follow
> assume the SSO-enabled nginx front-end (`local_cloud.sh`); running `chat-webui.py`
> directly on port 3001 bypasses all of it.

- **Bypass on bare `chat-webui.py`.** Running `python chat-webui.py` directly serves
  port 3001 **without** the nginx SSO gate, so `identity_from_headers` finds no
  `X-Authentik-*` headers and every endpoint treats the caller as unauthenticated.
  Always front it with `local_cloud.sh`'s nginx config for real protection.
- **File endpoints are unauthenticated on bare 3001.** `/output/...` (generated
  images) and `/uploads/...` (uploaded documents) are served without an identity
  check (`chat-webui.py` `do_GET`). Anyone who can reach port 3001 and knows a
  filename can download them. Behind the SSO nginx gate (`/ai/`, `/api/`) these are
  still protected because the whole location is gated at the edge.
- **CORS is wide open.** Responses carry `Access-Control-Allow-Origin: *`
  (`chat-webui.py` `do_OPTIONS`/`send_json`). A malicious page on the same origin
  context could call the API and read responses; SSO cookies are HttpOnly and
  SameSite-bound so cross-origin pages cannot use the session.
- **No TLS/HTTPS on bare 3001.** Login and chat content travel in plaintext if you
  connect without nginx. Always use the TLS-terminating nginx front-end
  (`local_cloud.sh`); never port-forward 3001/8081/8079 directly.
- **No content guardrails.** The model outputs whatever the loaded model produces; there
  is no moderation or kid-safe filter in the stack. Choose your model accordingly and
  set expectations for anyone using it.
- **Third-party calls.** `fetch_page` and location lookup call external services
  (SearXNG backends, `nominatim.openstreetmap.org`), and optional TTS can use
  Microsoft `edge-tts` unless you configure the local Piper voices. If strict data
  residency matters, disable or replace these.
- **Compose exposes SearXNG on all host interfaces** (`8080:8080`), while the host path
  binds it to `127.0.0.1`. Bind it to localhost if you don't need LAN-wide search.

## System Design

### 1. Infrastructure & Network

```mermaid
graph TD
    subgraph Hardware ["Hardware (RTX 3050 Laptop)"]
        HW1["GPU: NVIDIA RTX 3050 — 4 GB VRAM"]
        HW2["RAM: 16 GB"]
        HW3["Same dev machine hosts everything"]
        HW4["Target: 2–4 concurrent users"]
    end

    subgraph ExternalServices ["External Services"]
        Docker["Docker Engine"]
        Docker --> SearXNG["SearXNG Container\nlocalhost:8080\nRestart: unless-stopped"]
        Nginx["Nginx Reverse Proxy\nchat.local:80 → localhost:3001"]
        Avahi["avahi-daemon\nmDNS: chat.local"]
    end

    subgraph Network ["Network Topology"]
        LAN["LAN Devices"] -->|"http://chat.local"| Nginx
        Nginx -->|"proxy_pass\nUpgrade + X-Real-IP"| HTTPServer["chat-webui.py\n0.0.0.0:3001"]
        HTTPServer -->|"localhost:8081"| LLamaGPU["llama-server (GPU)\ninteractive UI users"]
        HTTPServer -->|"localhost:8079"| LLamaCPU["llama-server (CPU)\nself-chat agents"]
        HTTPServer -->|"localhost:8188"| ComfyUIRuntime["ComfyUI"]
        HTTPServer -->|"localhost:8080/search"| SearXNG
        HTTPServer -->|"nominatim.openstreetmap.org"| Nominatim["Reverse Geocoding"]
    end

    subgraph StartupOrder ["Startup (manual)"]
        SO1["1. llama-server (GPU)\n--port 8081"] --> SO2["2. llama-server (CPU)\n--port 8079"]
        SO2 --> SO3["3. ComfyUI\nvenv → python main.py --lowvram"]
        SO3 --> SO4["4. chat-webui.py\npython chat-webui.py"]
        SO5["chat-webui.py on boot:\nGPU /health OK? else restart_servers\n→ ensure CPU server → SearXNG\nreachable? else sys.exit(1)"]
    end
```

### 2. File Layout & Build

```mermaid
graph TD
    subgraph CodeRepo ["Code (~/local-ai/)"]
        CR1["chat-webui.py\nMain server — Python 3"]
        CR2["setup.sh\nBootstrap: installs deps,\nclones repos, creates templates"]
        CR3["dist/\nVite-built SPA frontend"]
        CR4["llama.cpp/ git clone\nBuilt: cmake -DGGML_CUDA=ON\nBinary: build/bin/llama-server"]
        CR5["ComfyUI/ git clone\nvenv: ComfyUI/venv/\nRequires: requirements.txt"]
    end

    subgraph DataDir ["Data (~/local-ai-files/)"]
        DF1["model.json\nLLM model ids:\ngpu (chat UI)\n+ cpu (self-chat)"]
        DF2["models.json\nComfyUI image model defs\nz_image (turbo)"]
        DF3["Authentik (external)\nUsers, groups, SSO roles\ncontexts/<user>.txt\nPer-user persistent context"]
        DF4["sys_prompt.txt\nSystem prompt template\n%model_list% %current_time%\n%current_location%"]
        DF5["session/sessions_<user>.json\nPer-user persisted chat sessions"]
        DF6["my-models/\nLLM GGUF model files"]
        DF7["ComfyUI/input/\nTemp files for image editing"]
        DF8["ComfyUI/output/\nGenerated/edited images"]
        DF9["contexts/\nPer-user persistent context"]
        DF10["searxng/\nSearXNG config volume"]
        DF11["uploads/\nUploaded files saved to disk\n(code/docs via /api/extract-file)"]
        DF12["tasks.db\nSQLite to-do tasks + reminders"]
    end

    subgraph BuildFlags ["Build Flags"]
        BF1["llama.cpp\ncmake -DGGML_CUDA=ON\n-DCMAKE_BUILD_TYPE=Release\n-j nproc"]
        BF2["ComfyUI\npip install requirements.txt\nin Python venv"]
        BF3["Frontend\nnpm install && npm run build\nVite to dist/"]
        BF4["System deps\ngit python3 cmake avahi-daemon\npdftotext catdoc antiword\nnginx docker.io"]
    end
```

### 3. Runtime Constants & Locks

```mermaid
graph TD
    subgraph RCNetwork ["Service URLs"]
        RC1["LLAMA_BASE = localhost:8081 (GPU)\nLLAMA_URL = /v1/chat/completions"]
        RC2["LLAMA_BASE_CPU = localhost:8079 (CPU)\nLLAMA_URL_CPU = /v1/chat/completions"]
        RC3["COMFYUI_URL = localhost:8188"]
        RC4["SEARXNG_URL = localhost:8080/search"]
        RC5["HOST = 0.0.0.0  PORT = 3001"]
    end

    subgraph RCLlama ["llama-server Args (two concurrent servers)"]
        RCL1["GPU: --host 0.0.0.0 --port 8081\n--n-gpu-layers 99 -fa on --jinja"]
        RCL2["CPU: --host 0.0.0.0 --port 8079\n--n-gpu-layers 0 -fa off\n--device none --jinja"]
        RCL3["--models-dir ~/local-ai-files/my-models/"]
        RCL4["GPU: --ctx-size 24576 (24K)\nCPU: --ctx-size 32768 (32K)"]
        RCL5["--reasoning-budget 4096\n(CPU server only)"]
        RCL6["-ctk q8_0 (KV cache quant, both)\n-ctv q8_0 (GPU only)\n-nkvo (CPU only)"]
        RCL7["--no-mmproj-offload on BOTH servers\n(multimodal projector stays in RAM —\notherwise its ~950 MiB on the 4 GiB card\nstarves the CPU server's worker buffers)"]
        RCL8["Routing: agent task → 8079 CPU,\nUI task → 8081 GPU (task_mode)"]
    end

    subgraph RCThermal ["Thermal and RAM Thresholds"]
        RT1["TEMP_THRESHOLD_ON = 85 C"]
        RT2["TEMP_THRESHOLD_OFF = 75 C"]
        RT3["RAM_EVAC_THRESHOLD = 95%"]
        RT4["RAM_RESUME_THRESHOLD = 70%"]
    end

    subgraph RCLimits ["Limits and Pools"]
        RL1["MAX_QUEUE_SIZE = 5 (per lane)"]
        RL2["MAX_INPUT_TOKENS = 24576 (24K)"]
        RL3["_llm_pools: gpu 1 / cpu 4 workers\n(CPU_PARALLEL_SLOTS = 4)"]
        RL4["_tool_pools: gpu 2 / cpu 2 workers"]
        RL5["Max tool rounds = 10"]
        RL6["Idle unload = 300s"]
        RL7["LLM timeout = 600s"]
        RL8["ComfyUI poll = 120s"]
        RL9["REASONING_BUDGET = 4096 (CPU server)"]
    end

    subgraph RCThreads ["Thread Pools and Locks"]
        LK1["_llm_pools: ThreadPoolExecutor\nper lane (gpu 1, cpu 4)\nLLM calls run per-lane"]
        LK2["_tool_pools: ThreadPoolExecutor\nper lane (gpu 2, cpu 2)"]
        LK3["_event_queue: queue.Queue\nDecouples dequeue from dispatch"]
        LK4["_image_queue + _image_worker\nSerializes image jobs\n(image loading can starve GPU)"]
        LK5["_data_lock: threading.Lock\nGuards sessions, tasks, model_status"]
        LK6["_model_transition_lock\nSerializes load/unload of LLM"]
        LK7["_tokens_lock\nGuards _agent_tokens/_agent_users\n(self-chat agent tracking)"]
        LK8["_queue_locks + _queue_conds\nPer-lane task queues\n(gpu lane + cpu lane)\nCondition variables"]
    end
```

### 4. Server Startup

```mermaid
graph TD
    A["python chat-webui.py"] --> A1["Load configs at module import:\nmodel.json, models.json,\nsys_prompt.txt"]
    A --> B["load_sessions()\nLoad per-user session files"]
    A1 & B --> C{"GPU llama-server /health\nHTTP GET localhost:8081?"}
    C -- "200 OK" --> C2{"CPU llama-server /health\nHTTP GET localhost:8079?"}
    C -- "Dead" --> Restart["restart_servers:\n1. kill_llama_server pkill -9\n2. kill_comfyui pkill main.py\n3. Spawn ComfyUI Popen (no poll here)\n4. Spawn GPU llama-server (8081)\n5. Spawn CPU llama-server (8079)\n6. Poll each /health 2s up to 120s\n7. Kill on timeout"]
    C2 -- "Dead" --> EnsureCPU["ensure_llama_server cpu:\nrestart CPU llama-server only\n(GPU stays running)"]
    C2 -- "200 OK" --> D{"SearXNG reachable\non localhost:8080?"}
    EnsureCPU --> D
    D -- "Yes" --> E["Start 7 Daemon Threads"]
    D -- "No" --> Exit["print ERROR & sys.exit(1)"]
    Restart --> D

    subgraph Daemons ["Background Daemon Threads"]
        E1["_event_loop\nSingle-threaded event dispatcher"]
        E2["_queue_worker gpu\nSequential dequeuer\n(GPU lane — UI users)"]
        E3["_queue_worker cpu\nSequential dequeuer\n(CPU lane — self-chat agents)"]
        E4["_image_worker\nSerialized image jobs\n(one at a time)"]
        E5["_idle_unload_loop\nPolls every 10s"]
        E6["_thermal_monitor\nPolls every 10s"]
        E7["_reminder_loop\nPolls every 30s"]
    end
    E --> E1 & E2 & E3 & E4 & E5 & E6 & E7
    E --> F["HTTPServer.serve_forever\n0.0.0.0:3001"]
```

### 5. Model State Machine

Two independent state machines run concurrently — one per llama-server.

```mermaid
stateDiagram-v2
    direction LR

    state "GPU server (8081) — UI users" as G {
        [*] --> unloaded: gpu
        unloaded --> loading : load_llama_model("gpu")
        loading --> chat_loaded : 200 from /models/load\n+ health check passes
        loading --> unloaded : failed
        chat_loaded --> unloading : unload_llama_model("gpu")
        unloading --> unloaded : 200 from /models/unload
        unloading --> chat_loaded : failed but health OK
        chat_loaded --> image_active : generate_image\nor edit_image starts
        image_active --> chat_loaded : free_comfyui_vram\n+ load_llama_model("gpu")
    end

    state "CPU server (8079) — self-chat agents" as C {
        [*] --> cpu_unloaded
        cpu_unloaded --> cpu_loading : load_llama_model("cpu")
        cpu_loading --> cpu_loaded : 200 from /models/load\n+ health check passes
        cpu_loading --> cpu_unloaded : failed
        cpu_loaded --> cpu_unloading : unload_llama_model("cpu")
        cpu_unloading --> cpu_unloaded : 200 from /models/unload
        cpu_unloading --> cpu_loaded : failed but health OK
    }
```

Image generation unloads **only** the GPU server; the CPU server keeps serving
agents throughout. Per-server idle timestamps drive independent unloads
(`_last_llm_use` for GPU, `_cpu_last_llm_use` for CPU).

### 6. REST API Endpoints

```mermaid
graph TD
    Client([User Client])

    subgraph AuthEndpoints ["Auth (SSO)"]
        Client -->|"Browser: nginx auth_request\n→ Authentik SSO portal"| SSO["SSO session cookie set\nX-Authentik-* forwarded upstream"]
        Client -->|"GET /api/check-auth"| CheckAuth["identity from\nX-Authentik-* headers"]
        CheckAuth -- Yes --> AuthOK["{authenticated: true, username, role}"]
        CheckAuth -- No --> AuthNO["{authenticated: false}"]
        Client -->|"Agents: POST /sso/... token\nAuthorization: Bearer <JWT>"| Bearer["Verify JWT against\nAuthentik JWKS"]
    end

    subgraph SessionEndpoints ["Session Management"]
        Client -->|"POST /api/sessions"| NewSession["Create UUID session\nStore in sessions_meta"]
        Client -->|"GET /api/sessions"| ListSessions["List user sessions\nSorted by updated desc"]
        Client -->|"GET /api/sessions/:id/messages"| GetMessages["Return messages\n+ token_estimate"]
        Client -->|"PUT /api/sessions/:id"| RenameSession["Rename session"]
        Client -->|"DELETE /api/sessions/:id"| DeleteSession["Delete session + cleanup\nassociated output images\nand uploaded files"]
    end

    subgraph TaskEndpoints ["Task Management"]
        Client -->|"GET /api/tasks"| ListTasks["List user tasks\nwith reminders"]
        Client -->|"POST /api/tasks"| CreateTask["Create task\n(title, priority, due_date, reminder)"]
        Client -->|"PUT /api/tasks/:id"| UpdateTask["Update task fields"]
        Client -->|"DELETE /api/tasks/:id"| DeleteTask["Delete task"]
    end

    subgraph AgentEndpoints ["Agent / Presence"]
        Client -->|"POST /api/register-agent"| RegisterAgent["Create agent token\nfor self-chat bots\n(kolpo, kaya, editor, moderator)"]
        Client -->|"POST /api/leaving"| Leaving["Set user's active window\nnow → end (presence)"]
        Client -->|"GET /api/active-users"| ActiveUsers["List currently active users\n(presence tracking)"]
    end

    subgraph UtilityEndpoints ["Utility"]
        Client -->|"GET /api/model-status"| ModelStatus["model_status, _last_tps\n_overheated, _gpu_temp,\nreminder_count, max_context"]
        Client -->|"POST /api/extract-file"| ExtractFile["Save uploaded file to disk\n(~/local-ai-files/uploads/)\nReturn {url, name}\nText extraction happens later\nvia the read_file tool"]
        Client -->|"POST /api/location"| SetLocation["Reverse geocode via Nominatim\nstore _client_location"]
        Client -->|"GET /api/user-context"| GetUserCtx["Read user context file"]
        Client -->|"POST /api/user-context\n{action: write|overwrite|read}"| SetUserCtx["Write / overwrite / read\nuser context file"]
        Client -->|"POST /api/tts"| TTS["Text-to-speech via Piper (local)\nor edge-tts (cloud fallback)"]
        Client -->|"GET /output/:filename"| ServeImage["Serve generated images\nfrom ComfyUI output dir (no auth)"]
        Client -->|"GET /uploads/:filename"| ServeUpload["Serve uploaded files\n(no auth)"]
    end

    subgraph SPA ["Static / SPA Serving"]
        Client -->|"GET /"| SPAIndex["Serve dist/index.html"]
        Client -->|"GET /*"| SPAAssets["Serve dist/ assets\nor SPA fallback to index.html"]
    end

    subgraph StatusPolling ["Status Polling"]
        Client -->|"GET /api/status/:task_id"| PollStatus["Return tasks id:\nstatus message response\n tools_used image etc"]
    end
```

### 7. Chat Ingress Flow

```mermaid
graph TD
    Client([User Client]) -->|"POST /api/chat\n(SSO session via nginx)"| EndpointChat

    EndpointChat["Handler.do_POST: /api/chat"]
    EndpointChat --> AuthCheck{"get_current_user\nvia X-Authentik-*/JWT"}
    AuthCheck -- No --> AuthErr[401 Unauthorized]
    AuthCheck -- Yes --> SessionCheck{"Session exists\nand owned by user?"}
    SessionCheck -- No --> SessionErr[404 Session not found]
    SessionCheck -- Yes --> RouteAtAdmission{"user in\n_agent_users?"}
    RouteAtAdmission -- "agent (cpu)" --> LaneCPU["lane = cpu\nQueue: _task_queues['cpu']"]
    RouteAtAdmission -- "user (gpu)" --> LaneGPU["lane = gpu\nQueue: _task_queues['gpu']"]
    LaneCPU --> QueueCheck{"len lane queue\n< MAX_QUEUE_SIZE 5?"}
    LaneGPU --> QueueCheck
    QueueCheck -- No --> QueueBusy[503 Server Busy]
    QueueCheck -- Yes --> EnqueueTask["lane queue.append\n_queue_conds[lane].notify"]
    EnqueueTask --> TaskInit["tasks task_id =\nstatus queued"]
    TaskInit --> ReturnTaskID["Return task_id to Client"]
    ReturnTaskID -. "mode resolved later\nin task_mode(task_id)" .-> TaskMode["agent → 8079 CPU\nuser → 8081 GPU"]
    TaskMode --> EnsureServer["ensure + load that\nmode's llama-server"]
```

### 8. Queue Workers (one per lane)

A separate worker drains each lane (`_queue_worker("gpu")`, `_queue_worker("cpu")`),
each with its own lock/condition/queue. The two lanes never wait behind each other;
they only share hardware when both need the GPU (chat load / image gen), which is
arbitrated separately. (The CPU-lane "yield to human presence" pause is disabled in
the current code — self-chat agents run on the CPU server continuously.)

```mermaid
graph TD
    E2["_queue_worker(mode)\ngpu or cpu"] --> QueueLoop["queue_cond[mode].wait\nblock on empty queue"]
    QueueLoop --> PauseCheck{"overheated and mode=gpu\nor ram_evacuating?"}
    PauseCheck -- Yes --> MarkWaiting["Set all queued tasks in\nthis lane to status waiting\npause label"]
    MarkWaiting --> PauseWait["queue_cond.wait 5s"] --> QueueLoop
    PauseCheck -- No --> PopTask["item = lane queue.pop 0\n_current_task_ids[mode] = task_id"]
    PopTask --> PostStart["event_post start\nsession_id message image\naudio user client_timestamp"]
    PostStart --> TaskDoneWait{"Poll tasks id.status\nevery 0.5s"}
    TaskDoneWait -- "done or error" --> ClearTask["_current_task_ids[mode] = None\nqueue_cond.notify_all"]
    ClearTask --> QueueLoop
```

### 9. Event Loop Pipeline

```mermaid
graph TD
    E1["_event_loop"] --> EvLoop["Loop: event_queue.get\nev_type task_id data"]
    EvLoop --> EvDispatch{"ev_type?"}

    EvDispatch -- "start" --> EvStart["Store task metadata:\n_tools_used, _search_details\n_original_message, _original_image\n_audio, _user, _client_timestamp"]
    EvStart --> PrepSession["prepare_session:\n1. Compute mode = task_mode(task_id)\n   (agent → cpu 8079, user → gpu 8081)\n2. Ensure + load that mode's server\n3. Inject sys prompt + date + location\n4. Inject user context\n5. Append user msg to session\n6. Auto-name session from message\n7. save_sessions\n[...]" ]
    PrepSession --> StartRound0["start_llm_round round 0"]

    EvDispatch -- "llm_ok" --> LLMOK{"state == llm_waiting\nand has tool_calls?"}
    LLMOK -- "No tools" --> Finalize["_finalize_task:\n1. Build msg_entry with reasoning\n   tools_used, image_url etc\n2. Append to session\n3. save_sessions\n4. tasks id = status done\n5. Reset + update that\n   mode's idle timestamp"]
    LLMOK -- "Has tools" --> SubmitTools["1. Append assistant msg\n2. state = tools_running\n3. pending_tools = count\n4. save_sessions\n5. Submit to that\n   task's lane tool pool"]

    EvDispatch -- "llm_err" --> LLMERR{"state == llm_waiting?"}
    LLMERR -- Yes --> LLMErrAction["_set_task_error:\ntasks id = status error"]
    LLMERR -- No --> EvLoop

    EvDispatch -- "tool_ok" --> ToolOK["1. Append tool result to session\n2. pending_tools minus 1\n3. save_sessions"]
    ToolOK --> AllToolsDone{"pending_tools <= 0?"}
    AllToolsDone -- No --> EvLoop
    AllToolsDone -- Yes --> NextRoundCheck{"round+1 < 10?"}
    NextRoundCheck -- Yes --> NextRound["start_llm_round round N+1\nFeed tool results back to LLM"]
    NextRoundCheck -- No --> MaxRoundsErr["_set_task_error:\nMax tool rounds exceeded"]

    EvDispatch -- "tool_err" --> ToolERR["1. Append error as tool result\n2. pending_tools minus 1\n3. save_sessions\n4. Same round-limit logic"]
```

### 10. LLM Worker

```mermaid
graph TD
    StartRound0["start_llm_round\n(mode from task_mode)"] --> LLMWorker["_llm_worker\nin _llm_pools[mode]\n(gpu 1 / cpu 4 workers)"]
    LLMWorker --> PayloadBuild["Build payload:\nmodel (mode's model id)\nmessages tools\ntool_choice auto\nmax_tokens 24576\nstream true\n(CPU server: --reasoning-budget 4096)"]
    PayloadBuild --> StreamReq["POST llama-server\n(mode's base: 8081 gpu / 8079 cpu)\nv1/chat/completions\nstream=True timeout=600s"]
    StreamReq --> StreamParse["Parse SSE stream:\n- reasoning_content delta\n  accumulate in reasoning_buf\n- content delta\n  accumulate in content_buf\n- tool_calls delta\n  reassemble by index[...]" ]
    StreamParse --> BuildAssistantMsg["Build assistant msg:\nrole assistant content\nreasoning_content tool_calls"]
    BuildAssistantMsg --> LLMOKPost["event_post llm_ok\nbody choices message"]
    LLMOKPost --> EvLoop["Back to _event_loop"]

    StreamReq -. "exception" .-> LLMException["event_post llm_err\nif image or vision in error\nuser-friendly message"]
```

### 11. Tool Worker

```mermaid
graph TD
    SubmitTools["Submit to _tool_pools[mode]\ngpu 2 / cpu 2 workers"] --> ToolWorker["_tool_worker\nin the task's lane pool"]
    ToolWorker --> ParseArgs["Parse tc.function.arguments\nfrom JSON string"]
    ParseArgs --> ChooseTool{"tc.function.name?"}

    ChooseTool -- "web_search" --> ExecSearch["1. set_status Searching\n2. Get _client_timestamp\n3. web_search query client_ts:\n   Append city to query\n   GET SearXNG search json\n   Return to[...]" ]
    ExecSearch --> ToolPost["event_post tool_ok"]

    ChooseTool -- "fetch_page" --> FetchPage["1. set_status Fetching\n2. fetch_page URL:\n   Validate URL (no private IPs)\n   GET with browser headers\n   Parse HTML (BeautifulSoup)\n   Return title + content"]
    FetchPage --> ToolPost

    ChooseTool -- "generate_image" --> GenGuard{"already generated\nimage this task?"}
    GenGuard -- Yes --> GenReject["Return error:\nImage generation limit reached"]
    GenGuard -- No --> EnqueueImage["_enqueue_image_job\nImage worker (single, serialized)"]
    EnqueueImage --> GenImage["1. unload_llama_model('gpu')\n2. Build ComfyUI workflow:\n   z_image 8 steps res_multistep\n   (default; sd3_5_medium 20\n   steps euler if in models.json)\n3. ensure_comfyui_running\n4. POST /prompt\n5. Poll history 120s\n6. free_comfyui_vram\n7. load_llama_model('gpu')\n8. 5s GPU cooldown\n(CPU agents keep running)"]
    GenImage --> ToolPost

    ChooseTool -- "edit_image" --> EditEnqueue["_enqueue_image_job\nImage worker (single, serialized)"]
    EditEnqueue --> EditImage["1. Find source image:\n   Check _image_url in session\n   Check base64 in user messages\n2. unload_llama_model('gpu')\n3. Write input to ComfyUI/input\n4. Build img2img workflow (denoise)\n5. ensure_comfyui_running\n6. POST /prompt\n7. Poll history 120s\n8. free_comfyui_vram\n9. load_llama_model('gpu')\n10. 5s GPU cooldown\n(CPU agents keep running)"]
    EditImage --> ToolPost

    ChooseTool -- "get_user_location" --> GetLoc["If _client_location cached: return it\nElse: set_status location_needed\nWait for browser geolocation\n(60s timeout)\nReturn location or 'denied'"]
    GetLoc --> ToolPost

    ChooseTool -- "read_file" --> ReadFile["1. Validate file_url in /uploads/\n2. Read file from uploads dir\n3. Extract text via:\n   fitz (PDF), python-docx (DOCX)\n   catdoc/antiword (DOC)\n   openpyxl (XLSX)\n4. Return content"]
    ReadFile --> ToolPost

    ChooseTool -- "update_user_context" --> ExecContext["write_user_context:\nAppend timestamped entry\nto user context file"]
    ExecContext --> ToolPost

    ChooseTool -- "manage_tasks" --> ManageTasks["SQLite tasks DB ops:\ncreate/update/complete/delete/list/get\nPer-user, with reminders"]
    ManageTasks --> ToolPost

    ChooseTool -- "unknown" --> ToolUnknown["Return error:\nUnknown tool"]
    ToolUnknown --> ToolPost
```

### 12. Resource Management

```mermaid
graph TD
    E4["_thermal_monitor"] --> ThermalLoop["Loop every 10s"]
    ThermalLoop --> CheckGPU["nvidia-smi GPU temp"]
    CheckGPU --> GPUTempCheck{"Temp >= 85 C?"}
    GPUTempCheck -- Yes --> SetOverheat["_overheated = True"]
    GPUTempCheck -- No --> CheckCool{"_overheated\nand Temp <= 75 C?"}
    CheckCool -- Yes --> UnsetOverheat["_overheated = False"]
    SetOverheat --> ThermalAction{"Is GPU lane\ntask running?"}
    ThermalAction -- No --> ThermalUnload["GPU model_status?"]
    ThermalUnload -- "chat_loaded" --> UnloadModel["unload_llama_model('gpu')\n(CPU server untouched)"]
    ThermalUnload -- "image_active" --> FreeVRAM["free_comfyui_vram"]
    ThermalAction -- Yes --> ThermalSkip["Skip let task finish"]
    UnsetOverheat --> RAMCheck1

    ThermalLoop --> RAMCheck1{"not evacuating\nand RAM >= 95%?"}
    RAMCheck1 -- Yes --> EvacuateRAM["_evacuate_ram:\n1. ram_evacuating = True\n2. Requeue in-flight task\n   to front of EACH lane\n   (gpu + cpu) status error\n3. kill_llama_server (both 8081 + 8079)\n4. kill_comfyui\n5. Wait until RAM <= 70%\n6. restart_servers()"]
    RAMCheck1 -- No --> ThermalLoop

    E3["_idle_unload_loop"] --> IdleLoop["Loop every 10s"]
    IdleLoop --> IdleCheck{"chat_loaded (gpu)\nidle > 300s\nno queue tasks?"}
    IdleCheck -- Yes --> UnloadModel2["unload_llama_model('gpu')\nRelease VRAM weights"]
    IdleCheck -- No --> IdleLoop
    IdleLoop --> IdleCheck2{"cpu_loaded\n_cpu_last_llm_use idle > 300s\nno queue tasks?"}
    IdleCheck2 -- Yes --> UnloadModel3["unload_llama_model('cpu')\nRelease RAM weights"]
    IdleCheck2 -- No --> IdleLoop
```
</file>

<file path="server/features/context.py">
"""Token estimation, context trimming and context compaction."""

import base64
import json
import os
import re

import requests

from server.features.state import M


def strip_html(text):
    text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL)
    text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL)
    text = re.sub(r"<[^>]+>", " ", text)
    text = re.sub(r"\s+", " ", text).strip()
    return text


def _text_tokens(s):
    if not s:
        return 0
    # Multilingual/non-ASCII characters eat up far more tokens (~2 chars per token vs ~4 for English)
    non_ascii = sum(1 for ch in s if ord(ch) > 0x7F)
    divisor = 2.0 if non_ascii > len(s) * 0.15 else 4.0
    return int(len(s) / divisor)


def estimate_tokens(messages, include_tools=True):
    total = M.TOOLS_TOKEN_COST if include_tools else 0

    for msg in messages:
        total += M.PER_MESSAGE_OVERHEAD
        content = msg.get("content", "")

        # Standard string content
        if isinstance(content, str):
            total += _text_tokens(content)

        # Multi-modal array content (text + images + audio)
        elif isinstance(content, list):
            for part in content:
                if not isinstance(part, dict):
                    continue
                ptype = part.get("type")
                if ptype == "text":
                    total += _text_tokens(part.get("text", ""))
                elif ptype in ("image_url", "input_image", "image"):
                    total += M.IMAGE_TOKEN_COST
                elif ptype in ("audio_url", "input_audio", "audio"):
                    total += M.AUDIO_TOKEN_COST

        # Tool call tokens
        for tc in msg.get("tool_calls") or []:
            total += _text_tokens(json.dumps(tc))

    # Return MUST be outside the for-loop!
    return max(1, total)


def trim_messages_for_context(messages):
    trimmed = list(messages)
    sys_msg = None
    if trimmed and trimmed[0].get("role") == "system":
        sys_msg = trimmed.pop(0)
    while estimate_tokens(trimmed) > M.MAX_INPUT_TOKENS and len(trimmed) > 1:
        trimmed.pop(0)
    if sys_msg:
        trimmed.insert(0, sys_msg)
    return trimmed


def _summarize_with_llm(text, mode="gpu"):
    payload = {
        "model": M.server_model_id(mode),
        "messages": [
            {
                "role": "system",
                "content": "You summarize conversations concisely, preserving key facts, decisions, user preferences, and unresolved questions.",
            },
            {"role": "user", "content": text},
        ],
        "max_tokens": 1024,
        "temperature": 0.3,
        "stream": False,
    }
    try:
        M.mark_slot_kv_dirty(mode)
        r = requests.post(M.server_url(mode), json=payload, timeout=120)
        r.raise_for_status()
        return r.json()["choices"][0]["message"]["content"]
    except Exception as e:
        print(f"[compact] LLM summarization failed: {e}")
        return None


def compact_messages_copy(messages, keep_messages=6, mode="gpu"):
    """Return a compacted COPY of the message list (summary + recent messages)
    WITHOUT modifying the stored session. Old messages are summarized, not deleted."""
    msgs = list(messages)
    sys_msg = None
    if msgs and msgs[0].get("role") == "system":
        sys_msg = msgs.pop(0)
    if len(msgs) <= keep_messages + 1:
        return ([sys_msg] + msgs) if sys_msg else msgs
    to_compact = msgs[:-keep_messages] if keep_messages > 0 else msgs
    recent = msgs[-keep_messages:] if keep_messages > 0 else []
    compact_text = ""
    for m in to_compact:
        role = m.get("role", "unknown")
        content = m.get("content", "")
        if isinstance(content, list):
            parts = []
            for p in content:
                if isinstance(p, dict):
                    if p.get("type") == "text":
                        parts.append(p.get("text", ""))
            content = " ".join(parts)
        if not content:
            continue
        compact_text += f"[{role}]: {content}\n\n"
    if not compact_text.strip():
        return ([sys_msg] + msgs) if sys_msg else msgs
    summary = M._summarize_with_llm(
        f"Compress the following conversation into a short paragraph, keeping all important details:\n\n{compact_text}",
        mode,
    )
    if summary is None:
        return ([sys_msg] + msgs) if sys_msg else msgs
    new_msgs = []
    if sys_msg:
        new_msgs.append(sys_msg)
    new_msgs.append({"role": "system", "content": f"[Compressed context]: {summary}"})
    new_msgs.extend(recent)
    return new_msgs


def sanitize_content_for_llm(messages):
    """Return a COPY of ``messages`` with content parts the LLM backend cannot
    process (e.g. ``audio_url``) removed, so a stale multimodal message can't
    make llama-server reject the whole request (HTTP 400 "unsupported
    content[].type"). The stored session is left untouched.
    """
    sanitized = []
    for msg in messages:
        content = msg.get("content")
        if not isinstance(content, list):
            sanitized.append(msg)
            continue
        parts = []
        dropped_audio = False
        for p in content:
            if not isinstance(p, dict):
                continue
            if p.get("type") in ("audio_url", "input_audio", "audio"):
                dropped_audio = True
                continue
            parts.append(p)
        if dropped_audio:
            parts.append(
                {
                    "type": "text",
                    "text": "[Voice message omitted — audio input is not supported by this model]",
                }
            )
        sanitized.append({**msg, "content": parts})
    return sanitized


def resolve_image_path(url):
    """Resolve a ``/uploads/`` or ``/output/`` URL to a local file path, or
    ``None`` if the URL is unknown or the file is missing."""
    if not url:
        return None
    fpath = None
    if url.startswith("/uploads/"):
        fname = os.path.basename(url.split("?", 1)[0])
        fpath = os.path.join(M.UPLOADS_DIR, fname)
    elif url.startswith("/output/"):
        rel = url[len("/output/"):].split("?", 1)[0]
        fpath = os.path.join(M.IMG_PATH, rel)
    if not fpath or not os.path.isfile(fpath):
        return None
    return fpath


def _image_to_data_url(url):
    """Resolve an image URL the server can serve to a ``data:`` URL.

    Understands ``/uploads/`` (user uploads) and ``/output/`` (generated
    images) so those bytes can be embedded in the LLM request. ``data:`` URLs
    pass through unchanged; anything unknown returns ``None``.
    """
    if not url:
        return None
    if url.startswith("data:"):
        return url
    fpath = resolve_image_path(url)
    if not fpath:
        return None
    ext = os.path.splitext(fpath)[1].lower()
    mime = {
        ".png": "image/png",
        ".jpg": "image/jpeg",
        ".jpeg": "image/jpeg",
        ".webp": "image/webp",
        ".gif": "image/gif",
    }.get(ext, "image/jpeg")
    try:
        with open(fpath, "rb") as f:
            raw = f.read()
    except OSError:
        return None
    return f"data:{mime};base64,{base64.b64encode(raw).decode()}"


def _reference_historical_images(messages):
    """Return a COPY of ``messages`` where ``image_url`` parts are kept lean.

    The image attached to the most recent user message stays visible (its bytes
    are embedded via ``_image_to_data_url``), preserving the current turn's
    vision. Every older image becomes a compact text marker carrying the file
    URL — the model can call ``read_image`` to actually view one, so history
    never drags megabytes of base64 through the context window.
    """
    last_user_idx = -1
    for i, m in enumerate(messages):
        if m.get("role") == "user":
            last_user_idx = i

    out = []
    for i, m in enumerate(messages):
        content = m.get("content")
        if not isinstance(content, list):
            out.append(m)
            continue
        parts = []
        for p in content:
            if isinstance(p, dict) and p.get("type") == "image_url":
                url = p.get("image_url", {}).get("url", "")
                if i == last_user_idx:
                    data_url = _image_to_data_url(url)
                    if data_url:
                        parts.append({"type": "image_url", "image_url": {"url": data_url}})
                        continue
                    parts.append({"type": "text", "text": f"[IMAGE: {url}]"})
                else:
                    parts.append(
                        {
                            "type": "text",
                            "text": f"[IMAGE: {url} — use the read_image tool to view this image]",
                        }
                    )
            else:
                parts.append(p)
        out.append({**m, "content": parts})
    return out


def _latest_read_image_url(messages):
    """Return the image URL of the most recent successful ``read_image`` tool
    result in ``messages`` (or ``None``)."""
    for m in reversed(messages):
        if m.get("role") != "tool":
            continue
        content = m.get("content")
        if not isinstance(content, str):
            continue
        try:
            data = json.loads(content)
        except (TypeError, ValueError):
            continue
        if data.get("ok") is True and data.get("image_url"):
            return data["image_url"]
    return None


def prepare_context_for_llm(sid, messages, mode="gpu"):
    """Build the message list to send to the LLM. When the conversation nears the
    context limit, old messages are summarized into a compressed context block —
    but the stored session is left untouched, so no messages are deleted.
    Historical images are referenced by path (see ``read_image``) instead of
    being re-sent as base64 on every round."""
    messages = sanitize_content_for_llm(messages)
    messages = _reference_historical_images(messages)
    total = estimate_tokens(messages)
    if total <= M.AUTO_COMPACT_THRESHOLD:
        context = trim_messages_for_context(messages)
        with M._effective_contexts_lock:
            M._effective_contexts.pop(sid, None)
        return context
    print(f"[context] Session {sid} estimate {total} tokens exceeds threshold {M.AUTO_COMPACT_THRESHOLD}; building compressed context for LLM")
    compacted = compact_messages_copy(messages, mode=mode)
    context = trim_messages_for_context(compacted)
    print(f"[context] Compressed context built; estimate after: {estimate_tokens(context)}")
    with M._effective_contexts_lock:
        M._effective_contexts[sid] = context
    return context


def effective_token_estimate(sid, messages):
    """Report the token count the UI shows: the compressed context actually sent
    to the LLM once compression has kicked in, falling back to the full history."""
    with M._effective_contexts_lock:
        cached = M._effective_contexts.get(sid)
    if cached is not None:
        return estimate_tokens(cached)
    return estimate_tokens(messages)


def context_token_report(sid, messages):
    """Token report for the UI: effective count sent to the LLM, the raw stored
    count, and whether context compression is currently active."""
    effective = effective_token_estimate(sid, messages)
    raw = estimate_tokens(messages)
    return {
        "token_estimate": effective,
        "raw_token_estimate": raw,
        "context_compressed": raw > effective,
    }
</file>

<file path="src/components/InputBar.jsx">
import { useState, useRef, useCallback } from 'react'
import { extractFile, uploadImage } from '../api'

const CODE_EXTS = new Set([
  '.py', '.js', '.ts', '.jsx', '.tsx', '.java', '.cpp', '.c', '.h', '.hpp',
  '.cs', '.go', '.rs', '.rb', '.php', '.swift', '.kt', '.scala', '.dart',
  '.sh', '.bash', '.pl', '.pm', '.lua', '.r', '.sql', '.html', '.css',
  '.scss', '.sass', '.less', '.vue', '.svelte', '.yaml', '.yml', '.json',
  '.xml', '.toml', '.ini', '.cfg', '.md', '.tex', '.dockerfile', '.tf',
  '.zig', '.nim', '.hs', '.ml', '.fs', '.erl', '.elm', '.purs', '.nix',
  '.ps1', '.bat', '.cmake', '.proto', '.gradle', '.bib',
])

const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'])
const DOC_EXTS = new Set(['.pdf', '.xls', '.xlsx', '.doc', '.docx'])
const MAX_FILE_SIZE = 10 * 1024 * 1024

function readFileAsBase64(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => resolve(reader.result.split(',')[1])
    reader.onerror = reject
    reader.readAsDataURL(file)
  })
}

export default function InputBar({ onSend, hasPending }) {
  const [text, setText] = useState('')
  const [research, setResearch] = useState(false)
  const [cpu, setCpu] = useState(false)
  const [attachedImage, setAttachedImage] = useState(null)
  const [attachedFile, setAttachedFile] = useState(null)
  const [attachedFileUrl, setAttachedFileUrl] = useState(null)
  const fileInputRef = useRef(null)
  const textareaRef = useRef(null)
  const imagePreviewRef = useRef(null)
  const sendingRef = useRef(false)

  function clearAttachments() {
    setAttachedImage(null)
    setAttachedFile(null)
    setAttachedFileUrl(null)
    if (fileInputRef.current) fileInputRef.current.value = ''
    if (imagePreviewRef.current) imagePreviewRef.current.style.display = 'none'
  }

  const uploadFileToServer = useCallback(async (file) => {
    const b64 = await readFileAsBase64(file)
    const data = await extractFile(file.name, b64)
    if (!data.url) throw new Error(data.error || 'Could not process file')
    setAttachedFile(data.name)
    setAttachedFileUrl(data.url)
  }, [])

  const handleFile = useCallback(async (e) => {
    const file = e.target.files[0]
    if (!file) return
    if (file.size > MAX_FILE_SIZE) {
      alert('File too large (max 10MB): ' + file.name)
      e.target.value = ''
      return
    }
    clearAttachments()
    const ext = '.' + file.name.split('.').pop().toLowerCase()

    if (IMAGE_EXTS.has(ext)) {
      const reader = new FileReader()
      reader.onload = (ev) => {
        const img = new Image()
        img.onload = async () => {
          let w = img.naturalWidth
          let h = img.naturalHeight
          const MAX_DIM = 1920
          if (w > MAX_DIM || h > MAX_DIM) {
            if (w > h) { h = Math.round(h * MAX_DIM / w); w = MAX_DIM }
            else { w = Math.round(w * MAX_DIM / h); h = MAX_DIM }
          }
          const c = document.createElement('canvas')
          c.width = w; c.height = h
          const ctx = c.getContext('2d')
          ctx.drawImage(img, 0, 0, w, h)
          const compressed = c.toDataURL('image/jpeg', 0.8)
          const b64 = compressed.split(',')[1]
          try {
            const res = await uploadImage(b64, 'jpg')
            setAttachedImage(res.url || b64)
          } catch {
            setAttachedImage(b64)
          }
          if (imagePreviewRef.current) {
            imagePreviewRef.current.src = compressed
            imagePreviewRef.current.style.display = 'block'
          }
        }
        img.src = ev.target.result
      }
      reader.readAsDataURL(file)
      return
    }

    if (CODE_EXTS.has(ext)) {
      try {
        await uploadFileToServer(file)
      } catch (err) {
        clearAttachments()
        alert('Error processing file: ' + err.message)
      }
      return
    }

    if (DOC_EXTS.has(ext)) {
      try {
        await uploadFileToServer(file)
      } catch (err) {
        clearAttachments()
        alert('Error processing file: ' + err.message)
      }
      return
    }

    const isCode = confirm('Unknown file type: ' + file.name + '. Is this a code/text file?')
    if (isCode) {
      try {
        await uploadFileToServer(file)
      } catch (err) {
        clearAttachments()
        alert('Error processing file: ' + err.message)
      }
    } else {
      clearAttachments()
    }
  }, [uploadFileToServer])

  async function handleSend() {
    if (sendingRef.current) return
    const msg = text.trim()
    if (!msg && !attachedImage && !attachedFileUrl) return
    sendingRef.current = true
    let finalText = msg
    if (attachedFileUrl) {
      finalText = '[FILE: ' + attachedFileUrl + '](' + attachedFile + ')\n\n' + (msg || 'See attached file above.')
    }
    try {
      await onSend(finalText, attachedImage, research, cpu)
    } finally {
      sendingRef.current = false
    }
    setText('')
    clearAttachments()
    if (textareaRef.current) {
      textareaRef.current.style.height = 'auto'
    }
  }

  const isMobile = window.matchMedia('(pointer: coarse)').matches;

  function handleKeyDown(e) {
    if (isMobile) return;
    if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
      e.preventDefault()
      handleSend()
    }
  }

  function handleInput() {
    if (textareaRef.current) {
      textareaRef.current.style.height = 'auto'
      textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px'
    }
  }

  function removeAttachedFile() {
    setAttachedFile(null)
    setAttachedFileUrl(null)
  }

  function handleResearchChange(e) {
    const checked = e.target.checked
    setResearch(checked)
    if (!checked) setCpu(false)
  }

  return (
    <div id="input-bar">
      <button id="attach-btn" onClick={() => fileInputRef.current?.click()}>+</button>
      <input type="file" id="file-input" ref={fileInputRef} onChange={handleFile} />
      <img id="image-preview" ref={imagePreviewRef} />
      {attachedFile && (
        <span id="file-badge" style={{ display: 'inline-flex' }}>
          <span>{attachedFile.length > 25 ? attachedFile.slice(0, 22) + '...' : attachedFile}</span>
          <span style={{ cursor: 'pointer', color: '#f87171', fontWeight: 'bold', marginLeft: 4 }} onClick={removeAttachedFile}>&#215;</span>
        </span>
      )}
      <textarea
        id="msg-input"
        ref={textareaRef}
        placeholder="Type a message..."
        rows={1}
        value={text}
        onChange={e => setText(e.target.value)}
        onKeyDown={handleKeyDown}
        onInput={handleInput}
      />
      <div id="research-toggles">
        <label id="research-toggle" title="Research mode — lets the agent search and read pages for up to 50 tool rounds until your question is fully answered.">
          <input type="checkbox" checked={research} onChange={handleResearchChange} />
          Research
        </label>
        <label id="cpu-toggle" className={research ? '' : 'disabled'} title={research ? "Run the research on the CPU-backed server instead of the GPU." : "Only available with Research mode."}>
          <input type="checkbox" checked={cpu} disabled={!research} onChange={e => setCpu(e.target.checked)} />
          CPU
        </label>
      </div>
      <button id="send-btn" onClick={handleSend}><span className="send-icon">&#10148;</span><span className="send-text">{hasPending ? 'Queue' : 'Send'}</span></button>
    </div>
  )
}
</file>

<file path="src/components/Message.jsx">
import { useMemo, useRef, useState, useCallback, useEffect, memo } from 'react'
import { marked } from 'marked'
import markedKatex from 'marked-katex-extension'
import DOMPurify from 'dompurify'
import { speak as apiSpeak, getTaskStatus as apiGetTaskStatus, shareMessage as apiShareMessage } from '../api'
import { downloadFile, toApiImage } from '../utils'
import StatusBox from './StatusBox'

marked.use(markedKatex({ throwOnError: false, nonStandard: true }))

// Research-mode citations are stored as `(Author, Venue, Year) [https://url]`
// (the critic parses the square-bracketed URL), but marked would swallow the
// trailing `]` into the link href. Turn `[https://url]` into a proper markdown
// link at render time so no stray bracket shows. Fenced code blocks are
// skipped to avoid corrupting their contents.
const normalizeCitationLinks = (text) => {
  if (!text) return text
  const RE = /\[(https?:\/\/[^\s\]<>]+)\](?!\s*\()/g
  return text
    .split(/(```[\s\S]*?```)/g)
    .map((seg, i) => (i % 2 === 1 ? seg : seg.replace(RE, '[$1]($1)')))
    .join('')
}

const fileLinkExt = {
  name: 'fileLink',
  level: 'inline',
  start(src) {
    const i = src.indexOf('[FILE:')
    return i === -1 ? undefined : i
  },
  tokenizer(src) {
    const match = /^\[FILE:\s*(\S+)\]\(([^)]+)\)/.exec(src)
    if (!match) return undefined
    return { type: 'fileLink', raw: match[0], url: match[1], name: match[2] }
  },
  renderer(token) {
    const url = token.url.replace(/"/g, '&quot;')
    const name = token.name.replace(/"/g, '&quot;')
    return (
      '<a class="file-chip" href="' + url + '" download="' + name + '" title="' + name + '">' +
      '<svg class="file-chip-icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">' +
      '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>' +
      '<polyline points="14 2 14 8 20 8"></polyline>' +
      '</svg>' +
      '<span class="file-chip-name">' + name + '</span>' +
      '</a>'
    )
  },
}

marked.use({ extensions: [fileLinkExt] })

function escHtml(s) {
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

function hostnameFromUrl(u) {
  try {
    return new URL(u).hostname
  } catch {
    return u || ''
  }
}

function formatElapsed(ms) {
  if (ms == null) return ''
  const s = ms / 1000
  if (s < 60) return s.toFixed(1) + 's'
  const m = Math.floor(s / 60)
  const rs = Math.round(s % 60)
  return m + 'm ' + rs + 's'
}

function execCopy(text) {
  const ta = document.createElement('textarea')
  ta.value = text
  ta.style.position = 'fixed'
  ta.style.opacity = '0'
  document.body.appendChild(ta)
  ta.select()
  let ok = false
  try {
    ok = document.execCommand('copy')
  } catch { }
  document.body.removeChild(ta)
  return ok
}

async function writeClipboard(text) {
  if (navigator.clipboard && navigator.clipboard.writeText) {
    await navigator.clipboard.writeText(text)
    return true
  }
  return execCopy(text)
}

function ShareButton({ sessionId, msgIndex, forceShow }) {
  const [busy, setBusy] = useState(false)
  const [error, setError] = useState('')
  const [share, setShare] = useState(null)

  async function handleShare(e) {
    e.preventDefault()
    e.stopPropagation()
    if (busy || !sessionId || msgIndex == null) return
    setBusy(true)
    setError('')
    try {
      const data = await apiShareMessage(sessionId, msgIndex)
      if (data && data.token) {
        setShare({ url: data.url })
      } else {
        setError((data && data.error) || 'Sharing failed')
      }
    } catch (err) {
      setError(err.message || 'Sharing failed')
    } finally {
      setBusy(false)
    }
  }

  async function copyLink() {
    if (!share) return
    const url = share.url.startsWith('http') ? share.url : window.location.origin + share.url
    await writeClipboard(url)
  }

  return (
    <>
      <button
        className={'share-btn' + (forceShow ? ' force-show' : '')}
        onClick={handleShare}
        title="Share this message"
        aria-label="Share this message"
        disabled={busy}
      >
        {busy ? 'Sharing…' : (
          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <circle cx="18" cy="5" r="3" />
            <circle cx="6" cy="12" r="3" />
            <circle cx="18" cy="19" r="3" />
            <line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
            <line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
          </svg>
        )}
      </button>
      {share && (
        <div className="share-modal-overlay" onClick={() => setShare(null)}>
          <div className="share-modal" onClick={e => e.stopPropagation()}>
            <div className="share-modal-header">
              <span>Message shared</span>
              <button type="button" className="share-modal-close" onClick={() => setShare(null)}>&#10005;</button>
            </div>
            <p className="share-modal-hint">Anyone on this network with the link can view this message without logging in.</p>
            <input
              readOnly
              className="share-modal-url"
              value={share.url.startsWith('http') ? share.url : window.location.origin + share.url}
              onFocus={e => e.target.select()}
              onKeyDown={e => e.key === 'Enter' && e.target.select()}
            />
            <div className="share-modal-actions">
              <button type="button" className="share-copy-btn" onClick={copyLink}>Copy link</button>
              <a className="share-open-link" href={share.url} target="_blank" rel="noreferrer">Open</a>
            </div>
          </div>
        </div>
      )}
      {error && <span className="share-error" role="alert">{error}</span>}
    </>
  )
}

function SearchPopup({ details }) {
  return (
    <div className="search-popup">
      <div style={{ marginBottom: 6 }}>
        <span style={{ color: '#888', fontSize: 11 }}>Query:</span>{' '}
        <span style={{ color: '#e0e0e0' }}>
          {details.map(sd => sd.query).join('; ')}
        </span>
      </div>
      {details.map((sd, i) => (
        <div key={i}>
          {sd.search_url && (
            <div style={{ marginBottom: 6, fontSize: 11, wordBreak: 'break-all' }}>
              <span style={{ color: '#888' }}>SearXNG:</span>{' '}
              <a href={sd.search_url} target="_blank" rel="noreferrer" style={{ color: '#60a5fa' }}>
                {sd.search_url}
              </a>
            </div>
          )}
          {sd.results && sd.results.map((r, j) => (
            <a
              key={j}
              href={r.url}
              target="_blank"
              rel="noreferrer"
              style={{ display: 'block', color: '#60a5fa', fontSize: 12, padding: '2px 0', textDecoration: 'none' }}
              title={r.url}
            >
              {r.title || r.url}
            </a>
          ))}
        </div>
      ))}
    </div>
  )
}

function CopyButton({ text, genPrompt, imageUrl, forceShow }) {
  const [label, setLabel] = useState('Copy')

  async function handleCopy() {
    setLabel('Copying...')
    try {
      await copyWithImage()
    } catch {
      copyTextOnly()
    }
  }

  async function copyWithImage() {
    let textContent = text || ''
    if (genPrompt) textContent = 'Prompt: ' + genPrompt + '\n\n' + textContent

    if (imageUrl && navigator.clipboard && navigator.clipboard.write) {
      const url = imageUrl.startsWith('http') ? imageUrl : window.location.origin + imageUrl
      try {
        const res = await fetch(url)
        const blob = await res.blob()
        const b64 = await blobToBase64(blob)
        const escaped = textContent.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
        const cleanHtml = '<html><body>' + escaped.replace(/\n/g, '<br>') + '<br><br><img src="' + b64 + '"></body></html>'

        await navigator.clipboard.write([
          new ClipboardItem({
            'text/html': new Blob([cleanHtml], { type: 'text/html' }),
            'text/plain': new Blob([escaped + '\n\n' + url], { type: 'text/plain' }),
          }),
          new ClipboardItem({ [blob.type]: blob }),
        ])
        setLabel('Copied!')
        setTimeout(() => setLabel('Copy'), 2000)
        return
      } catch { }
    }

    let copyText = textContent
    if (imageUrl) {
      const url = imageUrl.startsWith('http') ? imageUrl : window.location.origin + imageUrl
      copyText = (copyText ? copyText + '\n\n' : '') + url
    }

    if (copyText && navigator.clipboard && navigator.clipboard.writeText) {
      await navigator.clipboard.writeText(copyText)
      setLabel('Copied!')
      setTimeout(() => setLabel('Copy'), 2000)
      return
    }

    throw new Error('no clipboard method available')
  }

  function blobToBase64(blob) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader()
      reader.onloadend = () => resolve(reader.result)
      reader.onerror = reject
      reader.readAsDataURL(blob)
    })
  }

  function copyTextOnly() {
    let copyText = text || ''
    if (imageUrl) {
      const url = imageUrl.startsWith('http') ? imageUrl : window.location.origin + imageUrl
      copyText = (copyText ? copyText + '\n\n' : '') + url
    }
    if (genPrompt) copyText = 'Prompt: ' + genPrompt + '\n\n' + copyText

    try {
      if (navigator.clipboard && navigator.clipboard.writeText) {
        navigator.clipboard.writeText(copyText).then(() => {
          setLabel('Copied!')
          setTimeout(() => setLabel('Copy'), 2000)
        }).catch(() => fallbackExecCopy(copyText))
      } else {
        fallbackExecCopy(copyText)
      }
    } catch {
      fallbackExecCopy(copyText)
    }
  }

  function fallbackExecCopy(text) {
    const ta = document.createElement('textarea')
    ta.value = text
    ta.style.position = 'fixed'
    ta.style.opacity = '0'
    document.body.appendChild(ta)
    ta.select()
    try {
      document.execCommand('copy')
      setLabel('Copied!')
      setTimeout(() => setLabel('Copy'), 2000)
    } catch {
      setLabel('Failed')
      setTimeout(() => setLabel('Copy'), 2000)
    }
    document.body.removeChild(ta)
  }

  return <button className={'copy-btn' + (forceShow ? ' force-show' : '')} onClick={handleCopy}>{label}</button>
}

let _activeAudio = null

function SpeakButton({ text }) {
  const [speaking, setSpeaking] = useState(false)
  const idRef = useRef(null)

  async function handleClick() {
    const myId = (idRef.current = {})
    if (_activeAudio && _activeAudio._speakId === myId) {
      _activeAudio.pause()
      _activeAudio.currentTime = 0
      _activeAudio = null
      setSpeaking(false)
      return
    }
    if (_activeAudio) {
      _activeAudio.pause()
      _activeAudio.currentTime = 0
      _activeAudio = null
      setSpeaking(false)
    }
    setSpeaking(true)
    try {
      const data = await apiSpeak(text)
      if (idRef.current !== myId) return
      const mime = data.type || 'audio/mpeg'
      const audio = new Audio('data:' + mime + ';base64,' + data.audio)
      audio._speakId = myId
      audio.onended = () => {
        if (_activeAudio === audio) {
          _activeAudio = null
          setSpeaking(false)
        }
      }
      audio.onerror = () => { setSpeaking(false); _activeAudio = null }
      _activeAudio = audio
      audio.play()
    } catch (e) {
      console.warn('TTS error:', e)
      setSpeaking(false)
    }
  }

  return (
    <button
      className={'speak-btn' + (speaking ? ' speaking' : '')}
      onClick={handleClick}
      title={speaking ? 'Stop' : 'Read aloud'}
      aria-label={speaking ? 'Stop' : 'Read aloud'}
    >
      {speaking ? (
        <svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" aria-hidden="true">
          <rect x="6" y="5" width="4" height="14" rx="1" />
          <rect x="14" y="5" width="4" height="14" rx="1" />
        </svg>
      ) : (
        <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true">
          <path d="M8 5v14l11-7z" />
        </svg>
      )}
    </button>
  )
}

function ReasoningBlock({ text, open, onToggle }) {
  const preRef = useRef(null)
  const prevLenRef = useRef(0)
  const [copied, setCopied] = useState(false)

  useEffect(() => {
    if (!preRef.current || !text) return
    if (text.length > prevLenRef.current) {
      const el = preRef.current
      const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40
      if (atBottom) el.scrollTop = el.scrollHeight
    }
    prevLenRef.current = text.length
  }, [text])

  if (!text) return null
  const capped = text

  let html
  try {
    html = DOMPurify.sanitize(marked.parse(capped))
  } catch {
    html = escHtml(capped)
  }

  function handleCopy(e) {
    e.preventDefault()
    e.stopPropagation()
    writeClipboard(text).then((ok) => {
      setCopied(ok)
      setTimeout(() => setCopied(false), 2000)
    })
  }

  return (
    <details className="reasoning-block" open={open} onToggle={(e) => onToggle(e.target.open)}>
      <summary>
        <span className="reasoning-summary-label">Reasoning</span>
        {open && (
          <button type="button" className="reasoning-copy-btn" onClick={handleCopy} title="Copy reasoning">
            {copied ? 'Copied!' : 'Copy'}
          </button>
        )}
      </summary>
      <div className="reasoning-text" ref={preRef} dangerouslySetInnerHTML={{ __html: html }} />
    </details>
  )
}

function PendingMessage({ pending, onImageOpen, onResolved, onLocationNeeded, selectingRef }) {
  const [message, setMessage] = useState(pending.message || 'Thinking...')
  const [reasoning, setReasoning] = useState(pending.reasoning || '')
  const [reasoningOpen, setReasoningOpen] = useState(true)
  const resolvedRef = useRef(false)
  const locationNotifiedRef = useRef(false)

  useEffect(() => {
    const iv = setInterval(async () => {
      if (resolvedRef.current) return
      let st
      try {
        st = await apiGetTaskStatus(pending.taskId)
      } catch {
        return
      }
      if (!st || st.status === 'done' || st.status === 'error' || st.status === 'cancelled' || st.status === 'unknown' || st.status === 'not_found') {
        if (resolvedRef.current) return
        resolvedRef.current = true
        clearInterval(iv)
        onResolved(pending, st)
        return
      }
      if (st.message === 'location_needed') {
        if (!locationNotifiedRef.current) {
          locationNotifiedRef.current = true
          onLocationNeeded(pending.taskId)
        }
        return
      }
      locationNotifiedRef.current = false
      if (selectingRef && selectingRef.current) return
      setMessage(st.message || 'Working...')
      if (st.reasoning) setReasoning(prev => (prev === st.reasoning ? prev : st.reasoning))
    }, 1000)
    return () => clearInterval(iv)
  }, [pending, onResolved, onLocationNeeded, selectingRef])

  return (
    <>
      {pending._userMsg && (
        <Message
          msg={pending._userMsg}
          onImageOpen={onImageOpen}
          selectingRef={selectingRef}
          onResolved={onResolved}
          onLocationNeeded={onLocationNeeded}
        />
      )}
      <div className={`msg bot`}>
        <div className="msg-content">
          <StatusBox message={message} />
          <ReasoningBlock text={reasoning} open={reasoningOpen} onToggle={setReasoningOpen} />
        </div>
      </div>
    </>
  )
}

function Message({ msg, pending, sessionId, msgIndex, hideSpeak, onImageOpen, selectingRef, onResolved, onLocationNeeded }) {
  const elRef = useRef(null)
  const chatEl = useRef(null)
  const [popupVisible, setPopupVisible] = useState(null)
  const hideTimer = useRef(null)
  const [reasoningOpen, setReasoningOpen] = useState(!!pending)
  const [pageModal, setPageModal] = useState(null)
  const codeRef = useRef([])

  const showPopup = useCallback((idx) => {
    if (hideTimer.current) clearTimeout(hideTimer.current)
    setPopupVisible(idx)
  }, [])

  const hidePopup = useCallback(() => {
    hideTimer.current = setTimeout(() => setPopupVisible(null), 800)
  }, [])

  const role = pending ? 'bot' : msg.role === 'user' ? 'user' : 'bot';

  let text = ''
  let userImg = null
  let timestamp = null

  if (msg) {
    if (role === 'user') {
      if (typeof msg.content === 'string') {
        text = msg.content
      } else if (Array.isArray(msg.content)) {
        msg.content.forEach(part => {
          if (part.type === 'text') text += part.text
          else if (part.type === 'image_url') {
            const url = part.image_url.url
            if (url.startsWith('data:')) userImg = url.split(',')[1]
            else if (url.startsWith('/uploads/') || url.startsWith('/output/') || /^https?:/.test(url)) userImg = url
          }
        })
      }
      if (msg._timestamp) {
        try {
          const d = new Date(msg._timestamp)
          timestamp = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZoneName: 'short' })
        } catch { }
      }
    } else {
      text = typeof msg.content === 'string' ? msg.content : ''
    }
  }

  // 2. ALWAYS call useMemo before any conditional return statements
  const html = useMemo(() => {
    if (!text) return ''
    try {
      const codeBlocks = []
      const renderer = new marked.Renderer()
      renderer.code = ({ text: codeText, lang }) => {
        const idx = codeBlocks.length
        codeBlocks.push(codeText.replace(/\n$/, ''))
        const code = (codeText.replace(/\n$/, '') + '\n').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
        const langAttr = lang ? ` class="language-${lang.split(/\s/)[0]}"` : ''
        return `<div class="code-block"><button type="button" class="copy-code-btn" data-i="${idx}" title="Copy code">Copy</button><pre><code${langAttr}>${code}</code></pre></div>\n`
      }
      const out = DOMPurify.sanitize(marked.parse(normalizeCitationLinks(text), { renderer }))
      codeRef.current = codeBlocks
      return out
    } catch {
      return escHtml(text)
    }
  }, [text])

  if (pending) {
    return (
      <PendingMessage
        pending={pending}
        onImageOpen={onImageOpen}
        selectingRef={selectingRef}
        onResolved={onResolved}
        onLocationNeeded={onLocationNeeded}
      />
    )
  }

  if (msg.role === 'system' || msg.role === 'tool') return null

  const toolsUsed = msg._tools_used || []
  const searchDetails = msg._search_details || []
  const fetchDetails = searchDetails.filter(d => d && d.tool === 'fetch_page')
  const genPrompt = msg._gen_prompt
  const imageUrl = toApiImage(msg._image_url)
  const imageModel = msg._image_model
  const isUserImgUrl = typeof userImg === 'string' && (userImg.startsWith('/') || /^https?:/.test(userImg))
  const userImgSrc = userImg ? (isUserImgUrl ? toApiImage(userImg) : 'data:image/jpeg;base64,' + userImg) : null

  if (
    msg.role === 'assistant' &&
    !text &&
    !imageUrl &&
    !userImg &&
    !genPrompt &&
    msg.tool_calls &&
    msg.tool_calls.length > 0
  ) {
    return null
  }

  if (role === 'user') {
    if (typeof msg.content === 'string') {
      text = msg.content
      // @ts-ignore
    } else if (Array.isArray(msg.content)) {
      msg.content.forEach(part => {
        if (part.type === 'text') text += part.text
        else if (part.type === 'image_url') {
          const url = part.image_url.url
          if (url.startsWith('data:')) userImg = url.split(',')[1]
          else if (url.startsWith('/uploads/') || url.startsWith('/output/') || /^https?:/.test(url)) userImg = url
        }
      })
    }
    if (msg._timestamp) {
      try {
        const d = new Date(msg._timestamp)
        timestamp = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZoneName: 'short' })
      } catch { }
    }
  } else {
    text = typeof msg.content === 'string' ? msg.content : ''
  }

  const ttsText = text
  if (role === 'bot') text = text.replace(/^\s*\[(bn|hi|en)\]\s*/, '')

  async function handleContentClick(e) {
    const btn = e.target.closest('.copy-code-btn')
    if (!btn) return
    const idx = parseInt(btn.dataset.i, 10)
    const codeText = codeRef.current[idx]
    if (codeText == null) return
    const orig = btn.textContent
    const ok = await writeClipboard(codeText)
    btn.textContent = ok ? 'Copied!' : 'Failed'
    setTimeout(() => { btn.textContent = orig }, 2000)
  }

  if (role === 'user' && !text && !imageUrl && !userImg && !genPrompt) return null

  return (
    <div className={`msg ${role}`} ref={elRef}>
      {timestamp && <span className="msg-timestamp">{timestamp}</span>}
      <div className="msg-header">
        {role === 'user' && msg._research && (
        <span className="tool-badge research" title="This message was sent with the Research toggle on">Research</span>
      )}
      {role === 'bot' && msg._elapsed_ms != null && (
          <span className="msg-elapsed" title="Time from task start to completion">&#9202; {formatElapsed(msg._elapsed_ms)}</span>
        )}
        {role === 'bot' && toolsUsed.length > 0 && (() => {
          let fetchIdx = 0
          return toolsUsed.map((t, i) => {
            const isFetch = t === 'fetch_page'
            const fetchDetail = isFetch ? fetchDetails[fetchIdx++] : null
            return (
              <span
                key={i}
                className={`tool-badge ${t === 'web_search' ? 'search' : t === 'generate_image' ? 'image' : t === 'edit_image' ? 'edit' : t === 'fetch_page' ? 'fetch' : ''}`}
                onMouseEnter={() => t === 'web_search' && searchDetails.length > 0 && showPopup(i)}
                onMouseLeave={hidePopup}
              >
                {t === 'web_search' ? 'Web Search' : t === 'generate_image' ? `Image Gen${imageModel ? ' (' + imageModel + ')' : ''}` : t === 'edit_image' ? 'Edit Image' : t === 'fetch_page' ? (fetchDetail?.url ? 'Fetched Page · ' + hostnameFromUrl(fetchDetail.url) : 'Fetched Page') : t}
                {isFetch && fetchDetail && (
                  <button
                    type="button"
                    className="fetch-info-btn"
                    title="View page details"
                    onClick={e => { e.stopPropagation(); setPageModal(fetchDetail) }}
                  >
                    &#9432;
                  </button>
                )}
                {t === 'web_search' && searchDetails.length > 0 && popupVisible === i && (
                  <div
                    className="search-popup"
                    onMouseEnter={() => showPopup(i)}
                    onMouseLeave={hidePopup}
                  >
                    <SearchPopup details={searchDetails} />
                  </div>
                )}
              </span>
            )
          })
        })()}
        {role === 'bot' && text && !hideSpeak && <SpeakButton text={ttsText} />}
        <CopyButton text={text} genPrompt={genPrompt} imageUrl={imageUrl} />
        {role === 'bot' && sessionId && msgIndex != null && (
          <ShareButton sessionId={sessionId} msgIndex={msgIndex} />
        )}
      </div>
      {imageUrl && (
        <div className="image-wrap">
          <img
            src={imageUrl}
            style={{ maxWidth: '100%', borderRadius: 10, cursor: 'pointer' }}
            onClick={() => onImageOpen(imageUrl)}
            alt="Generated"
          />
          <div className="img-actions">
            <button type="button" className="img-download-btn" onClick={() => downloadFile(imageUrl, 'image.png')}>
              Download
            </button>
          </div>
        </div>
      )}
      {userImgSrc && (
        <img
          src={userImgSrc}
          style={{ maxWidth: '100%', borderRadius: 10, cursor: 'pointer' }}
          onClick={() => onImageOpen(userImgSrc)}
          alt="Uploaded"
        />
      )}
      {genPrompt && (
        <div style={{ whiteSpace: 'pre-wrap', fontSize: 12, color: '#888', marginBottom: 6, fontStyle: 'italic' }}>
          Prompt: {genPrompt}
        </div>
      )}
      <ReasoningBlock text={msg._reasoning} open={reasoningOpen} onToggle={setReasoningOpen} />
      {text ? (
        <div
          className="msg-content"
          onClick={handleContentClick}
          dangerouslySetInnerHTML={{ __html: html }}
        />
      ) : !imageUrl && !userImg ? (
        <div className="msg-content empty-response">
          <em>(No response text generated)</em>
        </div>
      ) : null}
      {pageModal && (
        <div className="page-modal-overlay" onClick={() => setPageModal(null)}>
          <div className="page-modal" onClick={e => e.stopPropagation()}>
            <div className="page-modal-header">
              <span>Fetched Page</span>
              <button type="button" className="page-modal-close" onClick={() => setPageModal(null)}>&#10005;</button>
            </div>
            {pageModal.error ? (
              <div className="page-modal-body error">
                <div className="page-modal-url">{pageModal.url}</div>
                <p className="page-modal-error-msg">{pageModal.error}</p>
              </div>
            ) : (
              <div className="page-modal-body">
                {pageModal.title && <div className="page-modal-title">{pageModal.title}</div>}
                <a className="page-modal-url" href={pageModal.url} target="_blank" rel="noreferrer">{pageModal.url}</a>
                <div className="page-modal-content">
                  {(pageModal.content || '(No readable text content extracted)').slice(0, 6000)}
                  {pageModal.content && pageModal.content.length > 6000 ? '\n...[truncated for display]' : ''}
                </div>
              </div>
            )}
          </div>
        </div>
      )}
    </div>
  )
}

export default memo(Message)
</file>

<file path="src/App.css">
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html, body {
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: #0f0f1a;
  color: #e0e0e0;
  font-family: 'Inter', 'SF Pro', '-apple-system', sans-serif;
}

#root {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
}

::-webkit-scrollbar { width: 6px }
::-webkit-scrollbar-track { background: transparent }
::-webkit-scrollbar-thumb { background: #333; border-radius: 3px }
::-webkit-scrollbar-thumb:hover { background: #555 }

/* ---- Fixed Header ---- */
#model-bar {
  display: flex !important;
  align-items: center;
  gap: 6px;
  padding: 6px 12px;
  background: #1a1a2e;
  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
  font-size: 12px;
  flex-shrink: 0;
  height: 48px;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 999;
}

#sidebar-toggle {
  background: none;
  border: none;
  color: #888;
  font-size: 22px;
  cursor: pointer;
  padding: 0 6px 0 0;
  line-height: 1;
  transition: color .2s;
}

#sidebar-toggle:hover {
  color: #fff;
}

#model-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  flex-shrink: 0;
}

#model-dot.chat_loaded {
  background: #4ade80;
  box-shadow: 0 0 8px rgba(74, 222, 128, 0.4);
}

#model-dot.image_active {
  background: #f472b6;
  box-shadow: 0 0 8px rgba(244, 114, 182, 0.4);
}

#model-dot.loading {
  background: #fbbf24;
  animation: pulse .8s ease-in-out infinite;
}

#model-dot.unloaded {
  background: #6b7280;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.3; }
}

#model-label {
  font-size: 12px;
  color: #e0e0e0;
}

/* ---- User Menu ---- */
#user-menu {
  position: relative;
  margin-left: auto;
  cursor: pointer;
}

#user-name {
  padding: 4px 10px;
  border-radius: 6px;
  font-size: 12px;
  color: #94a3b8;
  border: 1px solid rgba(255, 255, 255, 0.1);
  transition: all .2s;
  white-space: nowrap;
}

#user-name:hover {
  background: rgba(255, 255, 255, 0.04);
  border-color: rgba(255, 255, 255, 0.2);
}

#user-dropdown {
  display: none;
  position: absolute;
  top: calc(100% + 4px);
  right: 0;
  background: #1a1a2e;
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 8px;
  padding: 4px;
  z-index: 999;
  min-width: 100px;
  box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}

#user-dropdown.open {
  display: block;
}

#user-dropdown button {
  display: block;
  width: 100%;
  padding: 8px 12px;
  border: none;
  background: none;
  color: #f87171;
  font-size: 12px;
  cursor: pointer;
  border-radius: 6px;
  text-align: left;
  font-family: inherit;
}

#user-dropdown button:hover {
  background: rgba(248, 113, 113, 0.1);
}

#user-dropdown button.task-menu-item {
  position: relative;
  color: #94a3b8;
}

#user-dropdown button.task-menu-item:hover {
  background: rgba(255, 255, 255, 0.08);
  color: #fff;
}

/* ---- App Container ---- */
#app-container {
  display: flex;
  flex-direction: column;
  position: absolute;
  top: 48px;
  left: 0;
  right: 0;
  bottom: 0;
  overflow: hidden;
}

/* ---- Sidebar ---- */
#sidebar-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.6);
  z-index: 99;
  display: none;
  backdrop-filter: blur(4px);
}

#sidebar-overlay.open {
  display: block;
}

#sidebar {
  position: fixed;
  top: 48px;
  left: 0;
  width: 280px;
  height: calc(100% - 48px);
  padding-bottom: 16px;
  background: rgba(22, 22, 42, 0.95);
  backdrop-filter: blur(16px);
  border-right: 1px solid rgba(255, 255, 255, 0.06);
  z-index: 100;
  transform: translateX(-100%);
  transition: transform .35s cubic-bezier(.4, 0, .2, 1);
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

#sidebar.open {
  transform: translateX(0);
}

#sidebar-header {
  padding: 14px 16px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
  flex-shrink: 0;
}

#new-chat-btn {
  width: 100%;
  padding: 10px;
  border-radius: 10px;
  border: 1px solid rgba(255, 255, 255, 0.1);
  background: rgba(255, 255, 255, 0.04);
  color: #e0e0e0;
  cursor: pointer;
  font-size: 14px;
  font-weight: 500;
  transition: all .2s;
}

#new-chat-btn:hover {
  background: rgba(255, 255, 255, 0.08);
  border-color: rgba(255, 255, 255, 0.2);
}

#session-list {
  flex: 1;
  overflow-y: auto;
  overflow-x: hidden;
  padding: 8px;
}

.session-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 12px;
  border-radius: 8px;
  cursor: pointer;
  margin-bottom: 2px;
  transition: all .2s;
}

.session-item:hover {
  background: rgba(255, 255, 255, 0.04);
}

.session-item.active {
  background: rgba(74, 108, 247, 0.15);
  border-left: 2px solid #4a6cf7;
}

.session-name {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 13px;
  font-weight: 450;
}

.session-actions {
  display: flex;
  gap: 2px;
  opacity: 1;
}

.session-actions button {
  background: rgba(255, 255, 255, 0.08);
  border: none;
  cursor: pointer;
  padding: 4px;
  font-size: 14px;
  border-radius: 6px;
  color: #e0e0e0;
}

/* ---- Overload Warning ---- */
#overload-warn {
  animation: pulseWarn 1.5s ease-in-out infinite alternate;
  background: #991b1b;
  color: #fff;
  text-align: center;
  padding: 6px 12px;
  font-size: 13px;
  font-weight: 500;
}

@keyframes pulseWarn {
  from { opacity: .8; }
  to { opacity: 1; }
}

/* ---- Chat Area ---- */
#chat {
  flex: 1;
  overflow-y: auto;
  padding: 20px 20px;
  display: flex;
  flex-direction: column;
  gap: 16px;
  scroll-behavior: smooth;
  min-height: 0;
}

.msg {
  max-width: 78%;
  padding: 14px 18px;
  border-radius: 14px;
  line-height: 1.6;
  position: relative;
  animation: fadeIn .25s ease;
}

.msg-timestamp {
  position: absolute;
  top: 4px;
  right: 10px;
  font-size: 10px;
  color: rgba(255, 255, 255, 0.3);
  pointer-events: none;
  font-weight: 500;
  letter-spacing: 0.3px;
}

@keyframes fadeIn {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: translateY(0); }
}

.msg:hover .copy-btn {
  opacity: 1;
}

.user {
  background: linear-gradient(135deg, #2d3a5e, #3a4a7a);
  align-self: flex-end;
  border-bottom-right-radius: 4px;
  box-shadow: 0 2px 12px rgba(45, 58, 94, 0.3);
}

.bot {
  background: rgba(42, 42, 62, 0.8);
  backdrop-filter: blur(8px);
  align-self: flex-start;
  border-bottom-left-radius: 4px;
  border: 1px solid rgba(255, 255, 255, 0.04);
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
}

.bot img {
  max-width: 100%;
  border-radius: 10px;
  margin-top: 10px;
  cursor: pointer;
  transition: transform .2s;
}

.bot img:hover {
  transform: scale(1.02);
}

.msg-header {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-bottom: 6px;
  flex-wrap: wrap;
}

.msg-elapsed {
  font-size: 10px;
  color: rgba(255, 255, 255, 0.35);
  font-weight: 500;
  letter-spacing: 0.3px;
  margin-right: 4px;
  white-space: nowrap;
}

.copy-btn {
  opacity: 0;
  transition: opacity .2s;
  background: none;
  border: none;
  cursor: pointer;
  color: #666;
  font-size: 12px;
  padding: 2px 8px;
  border-radius: 6px;
  margin-left: auto;
  font-family: inherit;
}

.copy-btn:hover,
.copy-btn.force-show {
  color: #e0e0e0;
  background: rgba(255, 255, 255, 0.06);
}

.speak-btn {
  width: 26px;
  height: 26px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: rgba(96, 165, 250, 0.12);
  border: 1px solid rgba(96, 165, 250, 0.3);
  cursor: pointer;
  color: #60a5fa;
  padding: 0;
  flex-shrink: 0;
  transition: background .2s, color .2s, border-color .2s;
  font-family: inherit;
}

.speak-btn:hover {
  background: rgba(96, 165, 250, 0.22);
  color: #93c5fd;
}

.speak-btn.speaking {
  background: rgba(244, 114, 182, 0.15);
  border-color: rgba(244, 114, 182, 0.35);
  color: #f472b6;
}

.share-btn {
  width: 26px;
  height: 26px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: rgba(52, 211, 153, 0.12);
  border: 1px solid rgba(52, 211, 153, 0.3);
  cursor: pointer;
  color: #34d399;
  padding: 0;
  flex-shrink: 0;
  transition: background .2s, color .2s, border-color .2s;
  font-family: inherit;
}

.share-btn:hover {
  background: rgba(52, 211, 153, 0.22);
  color: #6ee7b7;
}

.share-btn:disabled {
  opacity: 0.6;
  cursor: default;
}

.share-error {
  color: #f87171;
  font-size: 11px;
  margin-left: 6px;
}

/* ---- Share Modal ---- */
.share-modal-overlay {
  position: fixed;
  top: 0; left: 0; right: 0; bottom: 0;
  background: rgba(0, 0, 0, 0.6);
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  backdrop-filter: blur(2px);
}

.share-modal {
  background: #1a1a2e;
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 12px;
  width: 100%;
  max-width: 460px;
  padding: 16px;
  box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
}

.share-modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  font-size: 14px;
  font-weight: 600;
  color: #e2e8f0;
  margin-bottom: 8px;
}

.share-modal-close {
  background: none;
  border: none;
  color: #94a3b8;
  font-size: 14px;
  cursor: pointer;
  padding: 4px;
  border-radius: 4px;
  line-height: 1;
  font-family: inherit;
}

.share-modal-close:hover {
  color: #fff;
  background: rgba(255, 255, 255, 0.08);
}

.share-modal-hint {
  color: #94a3b8;
  font-size: 12px;
  margin: 0 0 12px;
  line-height: 1.5;
}

.share-modal-url {
  width: 100%;
  box-sizing: border-box;
  padding: 10px 12px;
  border-radius: 8px;
  border: 1px solid rgba(255, 255, 255, 0.12);
  background: rgba(255, 255, 255, 0.05);
  color: #e0e0e0;
  font-size: 13px;
  font-family: monospace;
  outline: none;
  margin-bottom: 12px;
}

.share-modal-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

.share-modal-actions button,
.share-open-link {
  padding: 8px 14px;
  border-radius: 8px;
  border: none;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  text-decoration: none;
  font-family: inherit;
}

.share-copy-btn {
  background: linear-gradient(135deg, #34d399, #0ea5e9);
  color: #06281f;
}

.share-copy-btn:hover {
  filter: brightness(1.1);
}

.share-open-link {
  background: rgba(255, 255, 255, 0.08);
  color: #e2e8f0;
  border: 1px solid rgba(255, 255, 255, 0.12);
}

.share-open-link:hover {
  background: rgba(255, 255, 255, 0.14);
}

/* ---- Code Block Copy ---- */
.code-block {
  position: relative;
}
.code-block .copy-code-btn {
  position: absolute;
  top: 6px;
  right: 8px;
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid rgba(255, 255, 255, 0.08);
  color: #999;
  font-size: 11px;
  padding: 2px 8px;
  border-radius: 6px;
  cursor: pointer;
  opacity: 0;
  transition: opacity .2s;
  font-family: inherit;
  z-index: 1;
}
.code-block:hover .copy-code-btn {
  opacity: 1;
}
.code-block .copy-code-btn:hover {
  color: #e0e0e0;
  background: rgba(255, 255, 255, 0.12);
}

/* ---- File chip (uploaded files shown in messages) ---- */
.file-chip {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin: 2px 0;
  padding: 6px 12px;
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 8px;
  color: #e0e0e0;
  text-decoration: none;
  font-size: 13px;
  max-width: 100%;
  vertical-align: middle;
}
.file-chip:hover {
  background: rgba(255, 255, 255, 0.12);
  color: #fff;
}
.file-chip-icon {
  flex-shrink: 0;
  color: #60a5fa;
}
.file-chip-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ---- Image Download ---- */
.image-wrap > img {
  display: block;
  max-width: 100%;
}
.img-actions {
  display: flex;
  gap: 8px;
  margin-top: 8px;
}
.img-actions .img-download-btn {
  background: rgba(255, 255, 255, 0.08);
  border: 1px solid rgba(255, 255, 255, 0.12);
  color: #e0e0e0;
  font-size: 12px;
  padding: 4px 14px;
  border-radius: 6px;
  cursor: pointer;
  transition: background .2s;
  font-family: inherit;
}
.img-actions .img-download-btn:hover {
  background: rgba(255, 255, 255, 0.16);
  color: #fff;
}

/* ---- Tool Badges ---- */
.tool-badge {
  font-size: 11px;
  padding: 3px 10px;
  border-radius: 20px;
  font-weight: 500;
  letter-spacing: .3px;
  position: relative;
  cursor: default;
}

.tool-badge.search {
  background: rgba(30, 58, 95, 0.6);
  color: #60a5fa;
  cursor: pointer;
}

.tool-badge.image {
  background: rgba(59, 31, 59, 0.6);
  color: #f472b6;
}

.tool-badge.edit {
  background: rgba(20, 83, 45, 0.6);
  color: #34d399;
}

.tool-badge.fetch {
  background: rgba(124, 58, 237, 0.25);
  color: #a78bfa;
}

.tool-badge.research {
  background: rgba(16, 71, 62, 0.6);
  color: #5eead4;
}

.fetch-info-btn {
  background: none;
  border: 1px solid rgba(167, 139, 250, 0.4);
  color: #a78bfa;
  border-radius: 50%;
  width: 16px;
  height: 16px;
  line-height: 1;
  font-size: 10px;
  padding: 0;
  margin-left: 6px;
  cursor: pointer;
  vertical-align: middle;
  transition: all .15s;
}

.fetch-info-btn:hover {
  background: rgba(167, 139, 250, 0.2);
  border-color: #a78bfa;
}

/* ---- Page Details Modal ---- */
.page-modal-overlay {
  position: fixed;
  top: 0; left: 0; right: 0; bottom: 0;
  background: rgba(0, 0, 0, 0.6);
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  backdrop-filter: blur(2px);
}

.page-modal {
  background: #1a1a2e;
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 12px;
  width: 100%;
  max-width: 640px;
  max-height: 80vh;
  display: flex;
  flex-direction: column;
  box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
  overflow: hidden;
}

.page-modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  font-size: 13px;
  font-weight: 600;
  color: #e2e8f0;
}

.page-modal-close {
  background: none;
  border: none;
  color: #94a3b8;
  font-size: 14px;
  cursor: pointer;
  padding: 4px;
  border-radius: 4px;
  line-height: 1;
}

.page-modal-close:hover {
  color: #fff;
  background: rgba(255, 255, 255, 0.08);
}

.page-modal-body {
  padding: 16px;
  overflow-y: auto;
  font-size: 13px;
  color: #cbd5e1;
}

.page-modal-title {
  font-size: 15px;
  font-weight: 600;
  color: #f1f5f9;
  margin-bottom: 8px;
}

.page-modal-url {
  display: block;
  color: #60a5fa;
  word-break: break-all;
  font-size: 12px;
  margin-bottom: 12px;
  text-decoration: none;
}

a.page-modal-url:hover {
  text-decoration: underline;
}

.page-modal-content {
  white-space: pre-wrap;
  line-height: 1.6;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
  padding-top: 12px;
  font-size: 12px;
  color: #a5b4c8;
}

.page-modal-body.error .page-modal-error-msg {
  color: #f87171;
  background: rgba(248, 113, 113, 0.08);
  border: 1px solid rgba(248, 113, 113, 0.25);
  border-radius: 8px;
  padding: 12px;
  white-space: pre-wrap;
  word-break: break-word;
}

/* ---- Search Popup ---- */
.search-popup {
  display: block;
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 50;
  background: #1a1a2e;
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 10px;
  padding: 12px;
  padding-top: 18px;
  min-width: 300px;
  max-width: 420px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
  backdrop-filter: blur(12px);
}

.search-popup a {
  padding: 6px 0;
}

.search-popup a:hover {
  text-decoration: underline;
}

/* ---- Status Box ---- */
@keyframes spin {
  to { transform: rotate(360deg); }
}

.status-box {
  border: 1px solid #2a2a3e;
  border-radius: 8px;
  overflow: hidden;
  transition: border-color .2s;
}

.status-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 12px;
  font-size: 13px;
  color: #94a3b8;
  font-weight: 500;
}

.status-header .left-group {
  display: flex;
  align-items: center;
  gap: 8px;
}

.status-box .spinner {
  width: 12px;
  height: 12px;
  border: 2px solid #334155;
  border-top-color: currentColor;
  border-radius: 50%;
  animation: spin .8s linear infinite;
  flex-shrink: 0;
}

.status-box .status-text {
  color: #e2e8f0;
}

.status-box[data-state="thinking"] { border-color: #334155; }
.status-box[data-state="thinking"] .spinner { color: #60a5fa; }
.status-box[data-state="thinking"] .status-icon { color: #60a5fa; }

.status-box[data-state="search"] { border-color: #1e3a5f; }
.status-box[data-state="search"] .spinner { color: #60a5fa; }

.status-box[data-state="generate-image"] { border-color: #4a1a4a; }
.status-box[data-state="generate-image"] .spinner { color: #c084fc; }

.status-box[data-state="edit-image"] { border-color: #1a4a2a; }
.status-box[data-state="edit-image"] .spinner { color: #34d399; }

.reasoning-block {
  margin-top: 8px;
  background: rgba(255, 255, 255, 0.04);
  border-radius: 8px;
  border: 1px solid rgba(255, 255, 255, 0.08);
}
.reasoning-block summary {
  cursor: pointer;
  padding: 6px 10px;
  font-size: 12px;
  color: #ada9a9;
  user-select: none;
  display: flex;
  align-items: center;
  gap: 8px;
  list-style: none;
  transition: background .2s;
}
.reasoning-block summary::-webkit-details-marker {
  display: none;
}
.reasoning-block summary:hover {
  background: rgba(75, 70, 4, 0.158);
}
.reasoning-summary-label {
  flex: 1;
  display: flex;
  align-items: center;
  gap: 6px;
}
.reasoning-summary-label::before {
  content: '';
  width: 0;
  height: 0;
  border-left: 5px solid #888;
  border-top: 4px solid transparent;
  border-bottom: 4px solid transparent;
  transition: transform .2s;
}
.reasoning-block[open] .reasoning-summary-label::before {
  transform: rotate(90deg);
}
.reasoning-copy-btn {
  background: none;
  border: 1px solid rgba(255, 255, 255, 0.08);
  cursor: pointer;
  color: #666;
  font-size: 11px;
  padding: 1px 8px;
  border-radius: 6px;
  font-family: inherit;
}
.reasoning-copy-btn:hover {
  color: #e0e0e0;
  background: rgba(255, 255, 255, 0.06);
}
.reasoning-text {
  margin: 0;
  padding: 8px 12px;
  font-size: 12px;
  color: #bbb;
  line-height: 1.5;
  word-break: break-word;
  max-height: 300px;
  overflow-y: auto;
  border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.reasoning-text p { margin: 4px 0; }
.reasoning-text code {
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 12px;
  background: rgba(255, 255, 255, 0.06);
  padding: 1px 4px;
  border-radius: 3px;
}
.reasoning-text pre {
  background: rgba(0, 0, 0, 0.3);
  border-radius: 6px;
  padding: 8px;
  overflow-x: auto;
  margin: 6px 0;
}
.reasoning-text pre code { background: none; padding: 0; }
.reasoning-text ul, .reasoning-text ol { padding-left: 18px; margin: 4px 0; }
.reasoning-text blockquote { border-left: 2px solid #4a6cf7; padding-left: 10px; margin: 4px 0; color: #999; }

/* ---- Message Content ---- */
.msg-content p { margin: 4px 0; }

.msg-content pre {
  background: rgba(0, 0, 0, 0.3);
  border-radius: 8px;
  padding: 12px;
  overflow-x: auto;
  margin: 8px 0;
  border: 1px solid rgba(255, 255, 255, 0.04);
}

.msg-content code {
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 13px;
}

.msg-content p code {
  background: rgba(255, 255, 255, 0.06);
  padding: 2px 6px;
  border-radius: 4px;
  font-size: 13px;
}

.msg-content pre code {
  background: none;
  padding: 0;
}

.msg-content .mermaid {
  background: rgba(255,255,255,0.04);
  border-radius: 8px;
  padding: 16px;
  margin: 8px 0;
  overflow-x: auto;
  text-align: center;
}

.msg-content .mermaid svg {
  max-width: 100%;
  height: auto;
}

.reasoning-text .mermaid {
  background: rgba(255,255,255,0.04);
  border-radius: 8px;
  padding: 16px;
  margin: 8px 0;
  overflow-x: auto;
}

.msg-content ul,
.msg-content ol {
  padding-left: 20px;
  margin: 6px 0;
}

.msg-content blockquote {
  border-left: 3px solid #4a6cf7;
  padding-left: 12px;
  margin: 8px 0;
  color: #999;
}

.msg-content table {
  border-collapse: collapse;
  margin: 8px 0;
  width: 100%;
  font-size: 13px;
}

.msg-content th,
.msg-content td {
  padding: 6px 10px;
  border: 1px solid rgba(255, 255, 255, 0.08);
  text-align: left;
}

.msg-content th {
  background: rgba(255, 255, 255, 0.04);
  font-weight: 600;
}

.msg-content a {
  color: #60a5fa;
  text-decoration: none;
}

.msg-content a:hover {
  text-decoration: underline;
}

.msg-content h1, .msg-content h2, .msg-content h3, .msg-content h4 {
  margin: 10px 0 4px;
  font-weight: 600;
}

.msg-content h1 { font-size: 1.3em; }
.msg-content h2 { font-size: 1.15em; }
.msg-content h3 { font-size: 1.05em; }
.msg-content hr { border: none; border-top: 1px solid rgba(255, 255, 255, 0.06); margin: 12px 0; }

.msg-content.empty-response { color: #666; font-size: 13px; padding: 4px 0; }

.msg-content .katex { font-size: 1.05em; }
.msg-content .katex-display { margin: 8px 0; overflow-x: auto; overflow-y: hidden; }

/* ---- Input Bar ---- */
#input-bar {
  display: flex;
  gap: 8px;
  padding: 10px 20px 14px;
  background: rgba(15, 15, 26, 0.95);
  backdrop-filter: blur(12px);
  border-top: 1px solid rgba(255, 255, 255, 0.06);
  align-items: flex-end;
  flex-shrink: 0;
}

#msg-input {
  flex: 1;
  padding: 10px 16px;
  border-radius: 12px;
  border: 1px solid rgba(255, 255, 255, 0.08);
  background: rgba(255, 255, 255, 0.04);
  color: #e0e0e0;
  font-size: 14px;
  resize: none;
  outline: none;
  font-family: inherit;
  line-height: 1.5;
  max-height: 120px;
  transition: border-color .2s;
}

#msg-input:focus {
  border-color: rgba(74, 108, 247, 0.5);
}

#input-bar button {
  padding: 10px 18px;
  border-radius: 10px;
  border: none;
  cursor: pointer;
  font-size: 14px;
  font-weight: 500;
  transition: all .2s;
  flex-shrink: 0;
  font-family: inherit;
}

#send-btn {
  background: linear-gradient(135deg, #4a6cf7, #6a4cf7);
  color: #fff;
}

#send-btn:hover {
  transform: translateY(-1px);
  box-shadow: 0 4px 16px rgba(74, 108, 247, 0.35);
}

#send-btn:active {
  transform: translateY(0);
}

#send-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
  transform: none;
  box-shadow: none;
}

#send-btn .send-icon {
  display: none;
}

#research-toggle {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 8px 12px;
  border-radius: 10px;
  border: 1px solid rgba(255, 255, 255, 0.08);
  background: rgba(255, 255, 255, 0.04);
  color: #a0a0b8;
  font-size: 12px;
  font-weight: 500;
  cursor: pointer;
  flex-shrink: 0;
  user-select: none;
  transition: all .2s;
}

#research-toggles {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
}

#research-toggle:hover {
  border-color: rgba(74, 108, 247, 0.4);
  color: #e0e0e0;
}

#research-toggle:has(input:checked) {
  border-color: rgba(106, 76, 247, 0.6);
  color: #c4a6ff;
  background: rgba(106, 76, 247, 0.12);
}

#research-toggle input {
  accent-color: #6a4cf7;
  cursor: pointer;
}

#cpu-toggle {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 8px 12px;
  border-radius: 10px;
  border: 1px solid rgba(255, 255, 255, 0.08);
  background: rgba(255, 255, 255, 0.04);
  color: #a0a0b8;
  font-size: 12px;
  font-weight: 500;
  cursor: pointer;
  flex-shrink: 0;
  user-select: none;
  transition: all .2s;
}

#cpu-toggle:hover:not(.disabled) {
  border-color: rgba(74, 108, 247, 0.4);
  color: #e0e0e0;
}

#cpu-toggle:has(input:checked) {
  border-color: rgba(74, 108, 247, 0.6);
  color: #7fb8ff;
  background: rgba(74, 108, 247, 0.12);
}

#cpu-toggle.disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

#cpu-toggle input {
  accent-color: #4a6cf7;
  cursor: pointer;
}

#cpu-toggle.disabled input {
  cursor: not-allowed;
}

#attach-btn {
  background: rgba(255, 255, 255, 0.04);
  color: #888;
  font-size: 20px;
  padding: 10px 14px;
  border: 1px solid rgba(255, 255, 255, 0.06);
}

#attach-btn:hover {
  background: rgba(255, 255, 255, 0.08);
  color: #e0e0e0;
}

#image-preview {
  max-width: 100px;
  max-height: 70px;
  border-radius: 8px;
  display: none;
  object-fit: cover;
}

input[type=file] {
  display: none;
}

#file-badge {
  display: none;
  background: rgba(74, 108, 247, 0.15);
  padding: 4px 12px;
  border-radius: 8px;
  font-size: 12px;
  align-items: center;
  gap: 8px;
  max-width: 200px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  border: 1px solid rgba(74, 108, 247, 0.2);
}
/* ---- Image Lightbox ---- */
#image-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.85);
  z-index: 200;
  display: none;
  backdrop-filter: blur(8px);
  animation: fadeIn .2s ease;
  overflow: hidden;
  cursor: grab;
}

#image-overlay.open {
  display: block;
}

#image-overlay .img-wrap {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  pointer-events: none;
}

#image-overlay .img-wrap img {
  max-width: 92vw;
  max-height: 92vh;
  border-radius: 8px;
  box-shadow: 0 8px 48px rgba(0, 0, 0, 0.5);
  animation: fadeIn .25s ease;
  transition: transform .1s ease;
  transform-origin: center center;
  pointer-events: auto;
  user-select: none;
  -webkit-user-drag: none;
}

#image-overlay .zoom-label {
  position: fixed;
  bottom: 20px;
  left: 50%;
  transform: translate(-50%);
  color: #fff9;
  font-size: 13px;
  background: #00000080;
  padding: 4px 12px;
  border-radius: 6px;
  pointer-events: none;
}

/* ---- Login Screen ---- */
#login-overlay {
  position: fixed;
  top: 0; left: 0; right: 0; bottom: 0;
  background: #0f0f1a;
  z-index: 10000;
  display: flex;
  align-items: center;
  justify-content: center;
  animation: fadeIn .3s ease;
}

#login-overlay.hidden {
  display: none;
}

.login-box {
  background: rgba(22, 22, 42, 0.95);
  border: 1px solid rgba(255, 255, 255, 0.06);
  border-radius: 16px;
  padding: 40px;
  width: 340px;
  box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4);
}

.login-box h2 {
  margin-bottom: 24px;
  text-align: center;
  font-weight: 600;
  font-size: 22px;
}

.login-box input {
  width: 100%;
  padding: 12px 16px;
  margin-bottom: 12px;
  border-radius: 10px;
  border: 1px solid rgba(255, 255, 255, 0.08);
  background: rgba(255, 255, 255, 0.04);
  color: #e0e0e0;
  font-size: 14px;
  outline: none;
  font-family: inherit;
  box-sizing: border-box;
}

.login-box input:focus {
  border-color: rgba(74, 108, 247, 0.5);
}

.login-box button {
  width: 100%;
  padding: 12px;
  border-radius: 10px;
  border: none;
  background: linear-gradient(135deg, #4a6cf7, #6a4cf7);
  color: #fff;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  transition: all .2s;
  margin-top: 4px;
  font-family: inherit;
}

.login-box button:hover {
  transform: translateY(-1px);
  box-shadow: 0 4px 16px rgba(74, 108, 247, 0.35);
}

.login-box .login-error {
  color: #f87171;
  font-size: 13px;
  text-align: center;
  margin-top: 10px;
  display: none;
}

.login-box .login-error.show {
  display: block;
}

.login-share-divider {
  height: 1px;
  background: rgba(255, 255, 255, 0.08);
  margin: 18px 0 14px;
}

.login-share-input {
  margin-bottom: 8px;
}

.login-share-btn {
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid rgba(255, 255, 255, 0.12);
  color: #a5b4c8;
  font-weight: 500;
  font-size: 13px;
}

.login-share-btn:hover {
  background: rgba(255, 255, 255, 0.12);
  color: #e2e8f0;
  transform: none;
  box-shadow: none;
}

/* ---- Public Share View ---- */
#public-share-view {
  max-width: 720px;
  margin: 0 auto;
  padding: 24px 16px 48px;
  min-height: 100vh;
  box-sizing: border-box;
}

.public-share-topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 20px;
  padding-bottom: 12px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  flex-wrap: wrap;
}

.public-share-label {
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.4px;
  text-transform: uppercase;
  color: #94a3b8;
}

.public-share-exit {
  background: rgba(74, 108, 247, 0.12);
  border: 1px solid rgba(74, 108, 247, 0.3);
  color: #a5b4ff;
  border-radius: 8px;
  padding: 6px 12px;
  font-size: 13px;
  cursor: pointer;
  font-family: inherit;
}

.public-share-exit:hover {
  background: rgba(74, 108, 247, 0.22);
}

.public-share-meta {
  font-size: 12px;
  color: #94a3b8;
  margin-bottom: 12px;
}

.public-share-status {
  color: #94a3b8;
  font-size: 14px;
  padding: 24px 0;
}

.public-share-status.error {
  color: #f87171;
}

/* ---- Token Indicator ---- */
#token-indicator {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  color: #666;
}

#context-donut {
  flex-shrink: 0;
}

.token-compressed {
  color: #fbbf24;
  opacity: 0.9;
  margin-left: 4px;
  font-weight: 600;
}

#reminder-badge {
  position: absolute;
  top: -4px;
  right: -4px;
  background: #f87171;
  color: #fff;
  font-size: 9px;
  border-radius: 50%;
  width: 14px;
  height: 14px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 700;
}

/* ---- Task Panel ---- */
#task-panel {
  position: fixed;
  top: 48px;
  right: 0;
  width: 320px;
  max-height: calc(100vh - 48px);
  background: #1a1a2e;
  border-left: 1px solid rgba(255,255,255,0.08);
  padding: 8px;
  overflow-y: auto;
  z-index: 998;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

#task-panel-header {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px 6px;
  font-size: 13px;
  color: #94a3b8;
  font-weight: 600;
}

#task-panel-header button {
  background: none;
  border: 1px solid rgba(255,255,255,0.12);
  color: #94a3b8;
  border-radius: 4px;
  padding: 2px 8px;
  cursor: pointer;
  font-size: 13px;
}

#task-panel-header button:hover {
  background: rgba(255,255,255,0.06);
  color: #fff;
}

#task-panel-close {
  margin-left: auto;
}

#task-form {
  display: flex;
  gap: 4px;
  padding: 4px 6px;
}

#task-form input {
  flex: 1;
  background: rgba(255,255,255,0.06);
  border: 1px solid rgba(255,255,255,0.1);
  border-radius: 4px;
  padding: 4px 8px;
  color: #e2e8f0;
  font-size: 12px;
}

#task-form select {
  background: rgba(255,255,255,0.06);
  border: 1px solid rgba(255,255,255,0.1);
  border-radius: 4px;
  padding: 4px;
  color: #e2e8f0;
  font-size: 12px;
}

#task-form button {
  background: rgba(74,222,128,0.15);
  border: 1px solid rgba(74,222,128,0.3);
  color: #4ade80;
  border-radius: 4px;
  padding: 4px 10px;
  cursor: pointer;
  font-size: 12px;
}

#task-list {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.task-item {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px 6px;
  border-radius: 4px;
  font-size: 12px;
  transition: background .15s;
}

.task-item:hover {
  background: rgba(255,255,255,0.04);
}

.task-item.done .task-title {
  text-decoration: line-through;
  opacity: 0.4;
}

.task-item input[type="checkbox"] {
  accent-color: #4ade80;
  cursor: pointer;
}

.task-title {
  flex: 1;
  color: #e2e8f0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.task-status {
  font-size: 10px;
  color: #666;
}

.task-due {
  font-size: 10px;
  color: #666;
}

.task-delete {
  background: none;
  border: none;
  color: #666;
  cursor: pointer;
  padding: 0 2px;
  font-size: 12px;
  opacity: 0;
  transition: opacity .15s;
}

.task-item:hover .task-delete {
  opacity: 1;
}

.task-delete:hover {
  color: #f87171;
}

/* ---- Responsive ---- */
@media (max-width: 600px) {
  #model-tps {
    display: none;
  }

  .msg {
    max-width: 90%;
  }

  .session-actions {
    opacity: 1;
  }

  .copy-btn {
    opacity: 1;
  }

  .code-block .copy-code-btn {
    opacity: 1;
  }

  #input-bar {
    padding: 8px 12px 10px;
    gap: 6px;
    flex-wrap: wrap;
  }

  #research-toggles {
    order: 3;
    flex-basis: 100%;
    gap: 6px;
  }

  #research-toggle, #cpu-toggle {
    padding: 6px 10px;
    font-size: 11px;
  }

  #input-bar button {
    padding: 8px 10px;
    font-size: 14px;
  }

  #attach-btn {
    font-size: 16px;
    padding: 8px 10px;
  }

  #send-btn .send-icon {
    display: inline;
  }

  #send-btn .send-text {
    display: none;
  }

  #send-btn {
    padding: 8px 10px;
    font-size: 16px;
  }

  #msg-input {
    padding: 8px 12px;
    font-size: 13px;
  }

  #task-panel {
    width: 100%;
    max-width: 320px;
  }
}

#location-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

#location-dialog {
  background: #1a1a2e;
  border: 1px solid #333;
  border-radius: 12px;
  padding: 28px 32px;
  max-width: 380px;
  width: 90%;
  text-align: center;
}

#location-icon {
  font-size: 36px;
  margin-bottom: 12px;
}

#location-title {
  font-size: 18px;
  font-weight: 600;
  margin-bottom: 10px;
}

#location-desc {
  font-size: 14px;
  color: #999;
  line-height: 1.5;
  margin-bottom: 20px;
}

#location-actions {
  display: flex;
  gap: 10px;
  justify-content: center;
}

#location-deny-btn, #location-allow-btn {
  padding: 8px 24px;
  border-radius: 8px;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  border: none;
}

#location-deny-btn {
  background: #333;
  color: #ccc;
}

#location-allow-btn {
  background: #4a6cf7;
  color: #fff;
}

#location-deny-btn:hover {
  background: #444;
}

#location-allow-btn:hover {
  background: #5b7dfa;
}
</file>

<file path=".gitignore">
node_modules
dist
*.pyc
__pycache__/
logs/
*.log
nohup.out
repomix-output.xml
# Environment / secrets (fill in your real values; never commit these)
.env
</file>

<file path="server/features/tools.py">
"""LLM tool implementations: web search, page fetching, image tools dispatch."""

import concurrent.futures
import json
import os
import threading
from datetime import datetime
from urllib.parse import urlencode, urlparse

import requests

from server.features.state import M

# How many search results to hand back to the LLM, and how many of the top
# ones to enrich with the full page text (via fetch_page) so the LLM sees real
# content instead of only engine snippets.
WEB_SEARCH_RESULT_LIMIT = 10
WEB_SEARCH_ENRICH_TOP = 2
WEB_SEARCH_ENRICH_CHARS = 6000
WEB_SEARCH_ENRICH_TIMEOUT = 25

# Content-Types treated as plain readable text by fetch_page. Everything else
# (other binary/media types) is declined without ever being fed to the LLM.
_TEXTISH_TYPES = (
    "text/html",
    "text/plain",
    "application/xhtml",
    "application/json",
    "application/xml",
)

# Hard cap on how much text a parsed CSV/spreadsheet/PDF may yield before
# fetch_page stops reading rows/pages, so gigantic documents don't stall or
# blow up the response.
PARSE_ROW_LIMIT = 20000
PARSE_PDF_CHARS = 400000

# When a PDF has no extractable text (scanned pages), render up to this many
# pages to PNG files so the multimodal model can read the page images.
PDF_PAGE_IMAGE_LIMIT = 8
PDF_PAGE_IMAGE_ZOOM = 1.5


def _decode_response_text(raw):
    for enc in ("utf-8-sig", "utf-8", "latin-1"):
        try:
            return raw.decode(enc)
        except UnicodeDecodeError:
            continue
    return raw.decode("latin-1", errors="replace")


def _detect_doc_type(url, content_type, raw):
    """Classify fetched content as ``pdf``/``csv``/``excel`` or ``None``.

    Content-Type is trusted first, then the URL extension, then magic bytes.
    Anything unrecognized returns ``None`` so :func:`fetch_page` declines it as
    binary instead of trying to read it. Legacy ``.xls`` (OLE2 compound
    documents) is detected separately since no parser is available for it.
    """
    ext = os.path.splitext(urlparse(url).path)[1].lower()
    ctype = (content_type or "").split(";")[0].strip().lower()

    if ctype == "application/pdf" or ext == ".pdf" or raw.startswith(b"%PDF-"):
        return "pdf"
    if ctype == "text/csv" or ext == ".csv":
        return "csv"
    # Legacy binary OLE2 compound documents are .xls; we cannot parse those
    # without xlrd, so report them explicitly rather than guessing as binary.
    # Checked before the generic Excel Types because .xls is served as
    # application/vnd.ms-excel just like xlsx templates.
    if ext == ".xls" or raw.startswith(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"):
        return "excel_xls_unsupported"
    if ctype in (
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        "application/vnd.ms-excel",
        "application/excel",
    ) or ext in (".xlsx", ".xlsm", ".xltm", ".xltx"):
        return "excel"
    # Documents served as a generic octet-stream still get parsed if the URL
    # names a known document extension.
    if ctype == "application/octet-stream" and ext in (
        ".pdf",
        ".csv",
        ".xlsx",
        ".xlsm",
        ".xltm",
        ".xltx",
    ):
        return "pdf" if ext == ".pdf" else ("csv" if ext == ".csv" else "excel")
    return None


def _chunk_pages(text, size):
    """Split ``text`` into chunks of up to ``size`` characters."""
    if len(text) <= size:
        return [text]
    return [text[i : i + size] for i in range(0, len(text), size)]


def _doc_result(final_url, title, text, max_chars, chunk=1, page_images=None):
    """Build the ``fetch_page`` JSON payload.

    Long text is split into chunks of up to ``max_chars`` chars and only chunk
    number ``chunk`` is returned; the payload exposes ``total_chunks`` and
    ``next_chunk`` so the agent can page through the rest by calling
    ``fetch_page`` again with ``chunk=2,3,...``. ``page_images`` (rendered PDF
    pages) are attached alongside so a multimodal model can read scanned pages.
    """
    text = (text or "").strip() or "(No readable text content extracted)"
    pages = _chunk_pages(text, max_chars)
    idx = max(0, min(chunk - 1, len(pages) - 1))
    body = pages[idx]
    if page_images:
        body += (
            "\n\n[This PDF has no extractable text — the pages below were rendered "
            "as images. Use the read_image tool on each URL to view a page.]"
        )
    payload = {"url": final_url, "title": title, "content": body}
    if page_images:
        payload["page_images"] = page_images
    if len(pages) > 1:
        payload["chunk"] = idx + 1
        payload["total_chunks"] = len(pages)
        payload["next_chunk"] = idx + 2 if idx + 1 < len(pages) else None
        payload["note"] = (
            f"Page content is split across {len(pages)} chunks. Call fetch_page "
            f"again with chunk={idx + 2} to read the next chunk."
            if payload["next_chunk"]
            else "End of page content."
        )
    return json.dumps(payload, ensure_ascii=False)


def _render_pdf_pages(doc, url):
    """Render image-only PDF pages (scanned) to PNG files the model can view.

    Rendered files land under ``IMG_PATH/pdf_pages`` and are returned as
    ``/output/pdf_pages/...`` URLs so ``read_image`` / ``resolve_image_path``
    can resolve them. Returns an empty list when nothing could be rendered.
    """
    import hashlib

    slug = hashlib.md5(url.encode("utf-8", errors="replace")).hexdigest()[:12]
    outdir = os.path.join(M.IMG_PATH, "pdf_pages")
    try:
        os.makedirs(outdir, exist_ok=True)
    except OSError as e:
        print(f"[fetch_page] PDF page render dir failed: {e}")
        return []
    urls = []
    for i, page in enumerate(doc):
        if i >= PDF_PAGE_IMAGE_LIMIT:
            break
        try:
            import fitz

            pix = page.get_pixmap(
                matrix=fitz.Matrix(PDF_PAGE_IMAGE_ZOOM, PDF_PAGE_IMAGE_ZOOM)
            )
            fname = f"{slug}-p{i + 1}.png"
            pix.save(os.path.join(outdir, fname))
            urls.append(f"/output/pdf_pages/{fname}")
        except Exception as e:
            print(f"[fetch_page] PDF page {i + 1} render failed: {e}")
            break
    return urls


def _parse_pdf(raw, url):
    """Extract text from a PDF via PyMuPDF.

    Returns ``(text, title, page_images)``. If the PDF has no extractable text
    (scanned pages) the first ``PDF_PAGE_IMAGE_LIMIT`` pages are rendered to PNG
    files and returned as ``/output/pdf_pages/...`` URLs.
    """
    doc_title = os.path.basename(urlparse(url).path) or "PDF document"
    try:
        import fitz

        doc = fitz.open(stream=raw, filetype="pdf")
        parts = []
        total_chars = 0
        for page in doc:
            text = page.get_text("text")
            parts.append(text)
            total_chars += len(text)
            if total_chars > PARSE_PDF_CHARS:
                parts.append("\n...[truncated by size]")
                break
        body = "\n".join(parts).strip()
        page_images = []
        if not body:
            page_images = _render_pdf_pages(doc, url)
            body = "(No extractable text in PDF — the pages are likely scanned images.)"
        doc.close()
        return body, doc_title, page_images
    except Exception as e:
        print(f"[fetch_page] PDF parse failed: {e}")
        return f"(Could not extract text from this PDF: {e})", doc_title, []


def _parse_csv(raw, url):
    """Parse CSV text into pipe-separated rows. Returns ``(text, title)``."""
    import csv
    import io

    doc_title = os.path.basename(urlparse(url).path) or "CSV document"
    try:
        reader = csv.reader(io.StringIO(_decode_response_text(raw)))
        rows = []
        for i, row in enumerate(reader):
            if i >= PARSE_ROW_LIMIT:
                rows.append("...[truncated by row limit]")
                break
            rows.append(" | ".join("" if c is None else c.strip() for c in row))
        body = "\n".join(rows).strip() or "(Empty CSV)"
        return body, doc_title
    except Exception as e:
        print(f"[fetch_page] CSV parse failed: {e}")
        return f"(Could not parse this CSV: {e})", doc_title


def _parse_excel(raw, url):
    """Extract every sheet of an .xlsx workbook as text rows. Returns (text, title)."""
    import io

    from openpyxl import load_workbook

    doc_title = os.path.basename(urlparse(url).path) or "Excel spreadsheet"
    try:
        wb = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
        blocks = []
        for sheet in wb.worksheets:
            blocks.append(f"### Sheet: {sheet.title}")
            for row_idx, row in enumerate(sheet.iter_rows(values_only=True)):
                if row_idx >= PARSE_ROW_LIMIT:
                    blocks.append("...[truncated by row limit]")
                    break
                blocks.append(" | ".join("" if v is None else str(v) for v in row))
        wb.close()
        body = "\n".join(blocks).strip() or "(Empty spreadsheet)"
        return body, doc_title
    except Exception as e:
        print(f"[fetch_page] Excel parse failed: {e}")
        return f"(Could not parse this spreadsheet: {e})", doc_title


def web_search(query, current_time=None, current_location=None):
    ts = datetime.now()
    clean_query = query.strip()
    params = {"q": clean_query, "format": "json"}
    search_url = f"{M.SEARXNG_URL}?{urlencode(params)}"
    print("Performing web search", search_url)
    try:
        r = requests.get(M.SEARXNG_URL, params=params, timeout=10)
        r.raise_for_status()
        print("Web-search completed")
        data = r.json()
    except Exception as e:
        print(f"Web-search failed: {e}")
        return json.dumps({
            "results": [],
            "search_date": ts.strftime("%Y-%m-%d %A"),
            "query": query,
            "search_url": search_url,
            "error": str(e),
        })
    results = data.get("results", [])[:WEB_SEARCH_RESULT_LIMIT]
    formatted = []
    for x in results:
        formatted.append(
            {
                "title": x.get("title", ""),
                "url": x.get("url", ""),
                "snippet": x.get("content", "") or x.get("snippet", ""),
            }
        )
    enriched = _enrich_top_results(formatted)
    return json.dumps(
        {
            "results": enriched,
            "search_date": ts.strftime("%Y-%m-%d %A"),
            "query": query,
            "search_url": search_url,
        }
    )


def _enrich_top_results(results):
    """Attach the full page text to the top results.

    The top ``WEB_SEARCH_ENRICH_TOP`` results are fetched concurrently (their
    body is stored as ``full_content``) so the LLM does not have to make a
    separate ``fetch_page`` call for every promising link. Fetch failures are
    recorded as ``fetch_error`` and never break the search response.
    """
    targets = results[:WEB_SEARCH_ENRICH_TOP]
    if not targets:
        return results

    def _one(entry):
        url = entry.get("url", "")
        if not url.lower().startswith(("http://", "https://")):
            return
        try:
            page = json.loads(
                M.fetch_page(url, max_chars=WEB_SEARCH_ENRICH_CHARS)
            )
            if page.get("content"):
                entry["full_content"] = page["content"]
                entry["page_title"] = page.get("title", "")
            elif page.get("error"):
                entry["fetch_error"] = page["error"]
        except Exception as e:
            entry["fetch_error"] = str(e)

    executor = concurrent.futures.ThreadPoolExecutor(max_workers=len(targets))
    futs = [executor.submit(_one, entry) for entry in targets]
    concurrent.futures.wait(futs, timeout=WEB_SEARCH_ENRICH_TIMEOUT)
    executor.shutdown(wait=False)
    return results


def fetch_page(url, max_chars=24000, chunk=1):
    import ipaddress
    import socket

    from bs4 import BeautifulSoup

    if not url:
        return json.dumps({"url": "", "error": "No URL provided."})
    try:
        chunk = max(1, int(chunk or 1))
    except (TypeError, ValueError):
        chunk = 1
    try:
        parsed = urlparse(url)
        if parsed.scheme not in ("http", "https"):
            return json.dumps({"url": url, "error": "Only http/https URLs are supported."})
        host = parsed.hostname or ""
        ip = socket.gethostbyname(host)
        addr = ipaddress.ip_address(ip)
        if addr.is_private or addr.is_loopback or addr.is_link_local:
            return json.dumps({"url": url, "error": "Access to private/internal addresses is not allowed."})
    except Exception as e:
        return json.dumps({"url": url, "error": f"Invalid URL: {e}"})

    headers = {
        "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
        "Accept": "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8",
        "Accept-Language": "en-US,en;q=0.9",
    }
    try:
        r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
        r.raise_for_status()
        raw = getattr(r, "content", None)
        if raw is None:
            raw = r.text.encode("utf-8", errors="replace")
        ctype = r.headers.get("Content-Type", "").lower()
        kind = _detect_doc_type(url, ctype, raw)

        if kind == "pdf":
            text, doc_title, page_images = _parse_pdf(raw, url)
            return _doc_result(r.url, doc_title, text, max_chars, chunk, page_images)
        if kind == "csv":
            text, doc_title = _parse_csv(raw, url)
            return _doc_result(r.url, doc_title, text, max_chars, chunk)
        if kind == "excel":
            text, doc_title = _parse_excel(raw, url)
            return _doc_result(r.url, doc_title, text, max_chars, chunk)
        if kind == "excel_xls_unsupported":
            return json.dumps(
                {
                    "url": url,
                    "content_type": ctype,
                    "error": "This is a legacy .xls spreadsheet, which is not supported. "
                    "Please retry with a .xlsx or CSV version of the file.",
                }
            )

        if not any(t in ctype for t in _TEXTISH_TYPES):
            return json.dumps({"url": url, "content_type": ctype, "error": "Skipped: page is not readable text content (likely binary/media)."})
        if not r.encoding:
            r.encoding = r.apparent_encoding
        soup = BeautifulSoup(r.text, "html.parser")
        for tag in soup(["script", "style", "noscript", "svg", "nav", "footer", "header", "aside", "form"]):
            tag.decompose()
        title = soup.title.get_text(strip=True) if soup.title else ""
        main = soup.find("main") or soup.find("article") or soup.find("body") or soup
        text = main.get_text(separator="\n", strip=True)
        text = "\n".join(line.strip() for line in text.splitlines() if line.strip())
        return _doc_result(r.url, title, text, max_chars, chunk)
    except Exception as e:
        print(f"[fetch_page] Failed: {e}")
        return json.dumps({"url": url, "error": f"Failed to fetch page: {e}"})


def _tool_worker(task_id, sid, tc, image_b64, round_num, tool_index):
    tool_name = tc["function"]["name"]
    try:
        M._dispatch_tool(task_id, sid, tc, image_b64, round_num, tool_index)
    except Exception as e:
        print(f"[tool_worker] Tool '{tool_name}' crashed for task {task_id}: {e}")
        M._event_post(
            "tool_ok",
            task_id,
            tc_id=tc.get("id", ""),
            result=json.dumps({"error": f"Tool {tool_name} failed: {e}"}),
            sid=sid,
            round=round_num,
            tool_index=tool_index,
        )


def _dispatch_tool(task_id, sid, tc, image_b64, round_num, tool_index):
    tool_name = tc["function"]["name"]
    try:
        args = json.loads(tc["function"]["arguments"])
    except Exception:
        args = {}

    with M._data_lock:
        tu = list(M.tasks.get(task_id, {}).get("_tools_used", []))
    has_generated_image = "generate_image" in tu

    if tool_name == "get_user_location":
        if M._client_location:
            result = M._client_location
        else:
            ev = threading.Event()
            M._location_events[task_id] = ev
            M.set_status(task_id, "location_needed")
            ev.wait(timeout=60)
            M._location_events.pop(task_id, None)
            result = M._client_location if M._client_location else "User denied location access"
        M._event_post("tool_ok", task_id, tc_id=tc["id"], result=result, sid=sid, round=round_num, tool_index=tool_index)
        return

    if tool_name == "read_file":
        file_url = args.get("file_url", "")
        filename = os.path.basename(urlparse(file_url).path)
        fpath = os.path.abspath(os.path.join(M.UPLOADS_DIR, filename))
        if fpath.startswith(os.path.abspath(M.UPLOADS_DIR)) and os.path.exists(fpath):
            text = M.read_file_text(fpath)
            if text:
                result = f"Content of {file_url}:\n\n{text}"
            else:
                result = f"Could not extract text from {file_url}. The file may contain only images."
        else:
            result = f"File not found: {file_url}"
        M._event_post("tool_ok", task_id, tc_id=tc["id"], result=result, sid=sid, round=round_num, tool_index=tool_index)
        return

    if tool_name == "read_image":
        url = args.get("url", "")
        fpath = M.resolve_image_path(url)
        if fpath is None:
            result = json.dumps({"ok": False, "error": f"Image not found: {url}"})
        else:
            result = json.dumps({"ok": True, "image_url": url})
        M._event_post("tool_ok", task_id, tc_id=tc["id"], result=result, sid=sid, round=round_num, tool_index=tool_index)
        return

    if tool_name == "web_search":
        M.set_status(task_id, f"Searching web for: {args.get('query')}...")
        with M._data_lock:
            client_ts = M.tasks.get(task_id, {}).get("_client_timestamp")
        try:
            result = M.web_search(
                args["query"],
                current_time=args.get("current_time"),
                current_location=args.get("current_location"),
            )
        except Exception as e:
            print(f"[web_search] Unhandled exception for task {task_id}: {e}")
            result = json.dumps({"results": [], "query": args.get("query"), "error": str(e)})
        print(f"[web_search] RAW result for task {task_id}: {result[:300]}...")  # DEBUG
        with M._data_lock:
            t = M.tasks.get(task_id)
            if t:
                t.setdefault("_tools_used", []).append(tool_name)
                try:
                    t.setdefault("_search_details", []).append(json.loads(result))
                except Exception:
                    pass
        llm_result = (
            f"Web search results for query '{args.get('query')}'. "
            f"Analyze these search results thoroughly and provide a clear, accurate response based on the findings:\n\n{result}"
        )
        print(f"[web_search] LLM-bound result (with analysis instruction) for task {task_id}: {llm_result[:400]}...")  # DEBUG
        M._event_post(
            "tool_ok",
            task_id,
            tc_id=tc["id"],
            result=llm_result,
            sid=sid,
            round=round_num,
            tool_index=tool_index,
        )

    elif tool_name == "fetch_page":
        M.set_status(task_id, f"Fetching page: {args.get('url', '')}...")
        try:
            result = M.fetch_page(args.get("url", ""), chunk=args.get("chunk", 1))
        except Exception as e:
            print(f"[fetch_page] Unhandled exception for task {task_id}: {e}")
            result = json.dumps({"url": args.get("url", ""), "error": str(e)})
        print(f"[fetch_page] Result for task {task_id}: {result[:300]}...")  # DEBUG
        with M._data_lock:
            t = M.tasks.get(task_id)
            if t:
                t.setdefault("_tools_used", []).append(tool_name)
                try:
                    res = json.loads(result)
                    t.setdefault("_search_details", []).append({
                        "tool": "fetch_page",
                        "url": res.get("url", args.get("url", "")),
                        "title": res.get("title", ""),
                        "content": res.get("content", ""),
                        "error": res.get("error", ""),
                    })
                except Exception:
                    pass
        llm_result = (
            f"Page content fetched from URL '{args.get('url')}'. "
            f"Use this content to answer the user's question accurately. "
            f"If the content is insufficient or was truncated, you may fetch another page or fall back to the search results:\n\n{result}"
        )
        M._event_post(
            "tool_ok",
            task_id,
            tc_id=tc["id"],
            result=llm_result,
            sid=sid,
            round=round_num,
            tool_index=tool_index,
        )

    elif tool_name == "edit_image":
        M._enqueue_image_job(task_id, sid, tool_name, args, tc, round_num, tool_index)
        return

    elif tool_name == "generate_image":
        if has_generated_image:
            result = json.dumps(
                {"error": "Image generation limit reached for this prompt."}
            )
            M._event_post(
                "tool_ok",
                task_id,
                tc_id=tc["id"],
                result=result,
                sid=sid,
                round=round_num,
                tool_index=tool_index,
            )
        else:
            M._enqueue_image_job(task_id, sid, tool_name, args, tc, round_num, tool_index)
        return
    elif tool_name == "update_user_context":
        content = args.get("content", "")
        user = ""
        with M._data_lock:
            t = M.tasks.get(task_id)
            if t:
                user = t.get("_user", "")
        if user:
            M.write_user_context(user, content)
            print(f"[context] Updated context for user '{user}' ({len(content)} chars)")
        result = json.dumps({"status": "ok", "saved": bool(user)})
        M._event_post(
            "tool_ok",
            task_id,
            tc_id=tc["id"],
            result=result,
            sid=sid,
            round=round_num,
            tool_index=tool_index,
        )
    elif tool_name == "manage_tasks":
        user = ""
        with M._data_lock:
            t = M.tasks.get(task_id)
            if t:
                user = t.get("_user", "")
        if not user:
            result = json.dumps({"ok": False, "error": "User not found"})
        else:
            result = M.handle_task_tool(user, args)
        M._event_post(
            "tool_ok",
            task_id,
            tc_id=tc["id"],
            result=result,
            sid=sid,
            round=round_num,
            tool_index=tool_index,
        )
    elif tool_name == "track_theme":
        user = ""
        with M._data_lock:
            t = M.tasks.get(task_id)
            if t:
                user = t.get("_user", "")
        if not user:
            result = json.dumps({"ok": False, "error": "User not found"})
        else:
            result = M.handle_theme_tool(user, args)
        M._event_post(
            "tool_ok",
            task_id,
            tc_id=tc["id"],
            result=result,
            sid=sid,
            round=round_num,
            tool_index=tool_index,
        )
    else:
        result = json.dumps({"error": f"Unknown tool: {tool_name}"})
        M._event_post(
            "tool_ok",
            task_id,
            tc_id=tc["id"],
            result=result,
            sid=sid,
            round=round_num,
            tool_index=tool_index,
        )
</file>

<file path="src/api.js">
// Authentication is handled by nginx + Authentik SSO (the X-Authentik-*
// claim headers are injected upstream by nginx's auth_request). The browser
// never holds a token; on 401 nginx redirects to the SSO portal.
async function authFetch(url, options = {}) {
  options.headers = options.headers || {};
  const r = await fetch(url, options);
  if (r.status === 401) {
    window.dispatchEvent(new CustomEvent('auth:unauthorized'));
  }
  return r;
}

export async function logout() {
  window.location.assign('/outpost.goauthentik.io/sign_out?rd=/');
}

export async function checkAuth() {
  const r = await authFetch('/api/check-auth');
  return r.json();
}

export async function fetchSessions() {
  const r = await authFetch('/api/sessions');
  return r.json();
}

export async function fetchMessages(sessionId) {
  const r = await authFetch(`/api/sessions/${sessionId}/messages`);
  return r.json();
}

export async function createSession() {
  const r = await authFetch('/api/sessions', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name: 'New Chat' }),
  });
  return r.json();
}

export async function deleteSession(sessionId) {
  await authFetch(`/api/sessions/${sessionId}`, { method: 'DELETE' });
}

export async function renameSession(sessionId, name) {
  await authFetch(`/api/sessions/${sessionId}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name }),
  });
}

function localISOString() {
  const d = new Date()
  const tz = -d.getTimezoneOffset()
  const sign = tz >= 0 ? '+' : '-'
  const pad = n => String(Math.abs(n)).padStart(2, '0')
  return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) + 'T' +
    pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) +
    sign + pad(Math.floor(Math.abs(tz)/60)) + ':' + pad(Math.abs(tz)%60)
}

export async function sendMessage(sessionId, message, image, audio, clientTimestamp, research, cpu) {
  const body = { session_id: sessionId, message, client_timestamp: clientTimestamp || localISOString() };
  if (research) body.research = true;
  if (cpu) body.cpu = true;
  if (image) body.image = image;
  if (audio) body.audio = audio;
  const r = await authFetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (r.status === 503) {
    const err = await r.json();
    throw new Error(err.error || 'Server is busy');
  }
  return r.json();
}

export async function getTaskStatus(taskId) {
  const r = await fetch(`/api/status/${taskId}`);
  return r.json();
}

export async function getModelStatus() {
  const r = await fetch('/api/model-status');
  return r.json();
}

export async function sendLocation(latitude, longitude, taskId) {
  await fetch('/api/location', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ latitude, longitude, task_id: taskId }),
  });
}

export async function denyLocation(taskId) {
  await fetch('/api/location', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ denied: true, task_id: taskId }),
  });
}

export async function speak(text, voice) {
  const r = await authFetch('/api/tts', {
    method: 'POST',
    body: JSON.stringify({ text, voice: voice || undefined }),
  });
  return r.json();
}

export async function extractFile(name, dataB64) {
  const r = await authFetch('/api/extract-file', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name, data: dataB64 }),
  });
  return r.json();
}

export async function uploadImage(dataB64, ext = 'jpg') {
  const r = await authFetch('/api/upload-image', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ data: dataB64, ext }),
  });
  return r.json();
}

export async function fetchTasks() {
  const r = await authFetch('/api/tasks');
  return r.json();
}

export async function createTask(data) {
  const r = await authFetch('/api/tasks', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
  return r.json();
}

export async function updateTask(taskId, data) {
  const r = await authFetch(`/api/tasks/${taskId}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
  return r.json();
}

export async function deleteTask(taskId) {
  await authFetch(`/api/tasks/${taskId}`, { method: 'DELETE' });
}

export async function shareMessage(sessionId, msgIndex) {
  const r = await authFetch('/api/shares', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ session_id: sessionId, msg_index: msgIndex }),
  });
  return r.json();
}

export async function listShares() {
  const r = await authFetch('/api/shares');
  return r.json();
}

export async function revokeShare(token) {
  const r = await authFetch(`/api/shares/${token}`, { method: 'DELETE' });
  return r.json();
}

export async function fetchPublicShare(token) {
  const r = await fetch(`/api/public/share/${encodeURIComponent(token)}`);
  return r.json();
}
</file>

<file path="src/App.jsx">
import { useState, useEffect, useRef, useCallback } from 'react'
import * as api from './api'
import ModelBar from './components/ModelBar'
import Sidebar from './components/Sidebar'
import ChatArea from './components/ChatArea'
import InputBar from './components/InputBar'
import ImageLightbox from './components/ImageLightbox'
import OverloadWarning from './components/OverloadWarning'
import TaskPanel from './components/TaskPanel'
import LocationPrompt from './components/LocationPrompt'
import PublicShareView from './components/PublicShareView'

function shareTokenFromPath() {
  const m = /^\/s\/([A-Za-z0-9]+)\/?$/.exec(window.location.pathname)
  return m ? m[1] : null
}

export default function App() {
  const [authenticated, setAuthenticated] = useState(false)
  const [publicShareToken, setPublicShareToken] = useState(() => shareTokenFromPath())
  const [username, setUsername] = useState('')
  const [sessions, setSessions] = useState([])
  const [currentSessionId, setCurrentSessionId] = useState(null)
  const [messages, setMessages] = useState([])
  const [pendingMessages, setPendingMessages] = useState({})
  const [tokenEstimate, setTokenEstimate] = useState(0)
  const [contextCompressed, setContextCompressed] = useState(false)
  const [rawTokenEstimate, setRawTokenEstimate] = useState(0)
  const [maxContext, setMaxContext] = useState(24576)
  const [modelStatus, setModelStatus] = useState('unloaded')
  const [modelTps, setModelTps] = useState(null)
  const [overheated, setOverheated] = useState(false)
  const [gpuTemp, setGpuTemp] = useState(null)
  const [ramEvacuating, setRamEvacuating] = useState(false)
  const [reminderCount, setReminderCount] = useState(0)
  const [sidebarOpen, setSidebarOpen] = useState(false)
  const [lightboxSrc, setLightboxSrc] = useState(null)
  const [loadingSessions, setLoadingSessions] = useState({})
  const [showTasks, setShowTasks] = useState(false)
  const [showLocationPrompt, setShowLocationPrompt] = useState(false)
  const [locationTaskId, setLocationTaskId] = useState(null)
  const [locationError, setLocationError] = useState(null)
  const sessionRef = useRef(null)
  const selectingRef = useRef(false)
  const pendingMessagesRef = useRef({})
  const resolvedTasksRef = useRef(new Set())

  useEffect(() => {
    // Authentication is enforced by nginx's auth_request against Authentik:
    // by the time this SPA loads, the browser already has a valid SSO session
    // and /api/check-auth answers from the forwarded X-Authentik-* headers.
    api.checkAuth()
      .then(res => {
        if (res.authenticated) {
          setAuthenticated(true)
          if (res.username) setUsername(res.username)
          loadSessions().then(list => {
            const lastSid = localStorage.getItem('last_sid')
            if (lastSid && list.some(s => s.session_id === lastSid)) {
              switchSession(lastSid)
            } else if (list.length > 0) {
              switchSession(list[0].session_id)
            }
          })
        } else {
          setAuthenticated(false)
        }
      })
      .catch(() => setAuthenticated(false))
  }, [])

  useEffect(() => {
    const handler = () => {
      setAuthenticated(false);
      setUsername('');
      setSessions([]);
      sessionRef.current = null; setCurrentSessionId(null);
      setMessages([]);
      setPendingMessages({});
      setSidebarOpen(false);
    };
    window.addEventListener('auth:unauthorized', handler);
    return () => window.removeEventListener('auth:unauthorized', handler);
  }, [])

  const hasPendingForCurrent = Object.values(pendingMessages).some(
    p => p.sessionId === currentSessionId
  )

  useEffect(() => {
    pendingMessagesRef.current = pendingMessages
  }, [pendingMessages])

  // ---- Auth ----
  async function handleLogout() {
    console.log('[logout] called')
    await api.logout()
    setAuthenticated(false)
    setUsername('')
    setSessions([])
    sessionRef.current = null; setCurrentSessionId(null)
    setMessages([])
    setPendingMessages({})
    setSidebarOpen(false)
  }

  // ---- Sessions ----
  async function loadSessions() {
    const list = await api.fetchSessions()
    setSessions(list)
    return list
  }

  function loadSessionMessages(sid) {
    api.fetchMessages(sid).then(data => {
      if (sessionRef.current !== sid) return
      setMessages(data.messages || [])
      setTokenEstimate(data.token_estimate || 0)
      setContextCompressed(!!data.context_compressed)
      setRawTokenEstimate(data.raw_token_estimate || 0)
    }).catch(() => {
      if (sessionRef.current !== sid) return
      setMessages([])
      setTokenEstimate(0)
      setContextCompressed(false)
      setRawTokenEstimate(0)
    })
  }

  async function switchSession(sid) {
    sessionRef.current = sid; setCurrentSessionId(sid)
    localStorage.setItem('last_sid', sid)
    loadSessionMessages(sid)
    closeSidebar()
  }

  async function newChat() {
    const data = await api.createSession()
    sessionRef.current = data.session_id; setCurrentSessionId(data.session_id)
    localStorage.setItem('last_sid', data.session_id)
    setMessages([])
    setTokenEstimate(0)
    setContextCompressed(false)
    setRawTokenEstimate(0)
    const list = await loadSessions()
    setSessions(list)
    closeSidebar()
  }

  async function deleteSession_(sid) {
    if (!confirm('Delete this session?')) return
    await api.deleteSession(sid)
    setPendingMessages(prev => {
      const next = { ...prev }
      for (const [tid, p] of Object.entries(prev)) {
        if (p.sessionId === sid) delete next[tid]
      }
      return next
    })
    setStoredPending(getStoredPending().filter(p => p.sid !== sid))
    const list = await loadSessions()
    if (sid === currentSessionId) {
      if (list.length > 0) {
        switchSession(list[0].session_id)
      } else {
        newChat()
      }
    } else if (currentSessionId) {
      loadSessionMessages(currentSessionId)
    }
  }

  async function renameSession_(sid) {
    const s = sessions.find(x => x.session_id === sid)
    const newName = prompt('Session name:', s ? s.name : '')
    if (!newName || newName.trim() === '') return
    const prevSid = currentSessionId
    await api.renameSession(sid, newName.trim())
    const list = await loadSessions()
    sessionRef.current = prevSid; setCurrentSessionId(prevSid)
    setSessions(list)
  }

  function closeSidebar() {
    setSidebarOpen(false)
  }

  // ---- Chat / Send ----
  async function handleSend(text, image, research, cpu) {
    if (!currentSessionId) return
    const taskSid = currentSessionId
    setLoadingSessions(prev => ({ ...prev, [taskSid]: (prev[taskSid] || 0) + 1 }))

    const userMsg = { role: 'user', content: text || '\uD83D\uDCC4 file', _timestamp: new Date().toISOString() }

    try {
      const data = await api.sendMessage(currentSessionId, text || '', image || undefined, undefined, undefined, research, cpu)
      const taskId = data.task_id

      setPendingMessages(prev => ({
        ...prev,
        [taskId]: { sessionId: taskSid, status: 'working', message: 'Thinking...', taskId, reasoning: '', _userMsg: userMsg },
      }))

      const stored = getStoredPending()
      stored.push({ task_id: taskId, sid: taskSid })
      setStoredPending(stored)
    } catch (err) {
      setMessages(prev => [...prev, userMsg, { role: 'assistant', content: 'Error: ' + err.message }])
    }

    setLoadingSessions(prev => {
      const next = { ...prev }
      next[taskSid] = (next[taskSid] || 1) - 1
      if (!next[taskSid]) delete next[taskSid]
      return next
    })
  }

  // ---- Task completion callbacks (called by the active PendingMessage) ----
  const handlePendingResolved = useCallback((pending, st) => {
    const taskId = pending.taskId
    if (resolvedTasksRef.current.has(taskId)) return
    resolvedTasksRef.current.add(taskId)
    setPendingMessages(prev => {
      const next = { ...prev }
      delete next[taskId]
      return next
    })
    if (st.status === 'done') {
      const userMsg = pending._userMsg
      const assistantMsg = {
        role: 'assistant',
        content: st.response || '',
        _elapsed_ms: st._elapsed_ms != null ? st._elapsed_ms : null,
        _reasoning: st.reasoning || '',
        _image_url: st.image || st._image_url,
        _gen_prompt: st.gen_prompt,
        _tools_used: st.tools_used || [],
        _image_model: st._image_model,
        _search_details: st._search_details || [],
      }
      if (pending.sessionId === sessionRef.current) {
        setMessages(prev => [...prev, ...(userMsg ? [userMsg] : []), assistantMsg])
      }
      if (st.token_estimate != null) setTokenEstimate(st.token_estimate)
      setContextCompressed(!!st.context_compressed)
      if (st.raw_token_estimate != null) setRawTokenEstimate(st.raw_token_estimate)
      if (st.predicted_per_second != null) setModelTps(st.predicted_per_second)
      if (st.session_name != null && st.session_id) {
        setSessions(prev => prev.map(s => s.session_id === st.session_id ? { ...s, name: st.session_name } : s))
      }
    } else {
      if (pending.sessionId === sessionRef.current) {
        setMessages(prev => [...prev, ...(pending._userMsg ? [pending._userMsg] : []), {
          role: 'assistant',
          content: 'Error: ' + (st.error || 'Task was lost — please retry.'),
        }])
      }
    }
    const remaining = getStoredPending().filter(p => p.task_id !== taskId)
    setStoredPending(remaining)
  }, [])

  const handleLocationNeeded = useCallback((taskId) => {
    setShowLocationPrompt(true)
    setLocationTaskId(taskId)
    setLocationError(null)
  }, [])

  const closeLightbox = useCallback(() => setLightboxSrc(null), [])

  // ---- Background task resolution ----
  // Foreground pending tasks are self-polled by their PendingMessage (so only that
  // message re-renders). This loop resolves tasks whose session is not currently open.
  useEffect(() => {
    if (!authenticated) return
    const interval = setInterval(async () => {
      for (const [taskId, p] of Object.entries(pendingMessagesRef.current)) {
        if (p.sessionId === sessionRef.current) continue
        try {
          const st = await api.getTaskStatus(taskId)
          const terminal = st && (st.status === 'done' || st.status === 'error' || st.status === 'cancelled' || st.status === 'unknown' || st.status === 'not_found')
          if (terminal) {
            handlePendingResolved(p, st)
          } else if (st.message === 'location_needed') {
            handleLocationNeeded(taskId)
          }
        } catch { }
      }
    }, 2000)
    return () => clearInterval(interval)
  }, [authenticated, handlePendingResolved, handleLocationNeeded])

  // ---- Model status polling ----
  useEffect(() => {
    if (!authenticated) return
    const interval = setInterval(async () => {
      try {
        const data = await api.getModelStatus()
        setModelStatus(data.model)
        setOverheated(data.overheated)
        setGpuTemp(data.gpu_temp)
        if (data.ram_evacuating != null) setRamEvacuating(data.ram_evacuating)
        if (data.predicted_per_second != null) setModelTps(data.predicted_per_second)
        if (data.max_context != null) setMaxContext(data.max_context)
        if (data.reminder_count != null) setReminderCount(data.reminder_count)
      } catch { /* ignore */ }
    }, 2000)
    return () => clearInterval(interval)
  }, [authenticated])

  // ---- Resume pending tasks on page load ----
  useEffect(() => {
    if (!authenticated) return
    const stored = getStoredPending()
    if (stored.length > 0) {
      const pMap = {}
      stored.forEach(item => {
        pMap[item.task_id] = { sessionId: item.sid, status: 'working', message: 'Thinking...', taskId: item.task_id, reasoning: '' }
      })
      setPendingMessages(pMap)
    }
  }, [authenticated])

  function handleLocationAllow() {
    const tid = locationTaskId
    navigator.geolocation.getCurrentPosition(
      pos => {
        setShowLocationPrompt(false)
        setLocationTaskId(null)
        api.sendLocation(pos.coords.latitude, pos.coords.longitude, tid)
      },
      err => {
        if (err.code === 1) {
          setLocationError('Location access is blocked in your browser. Please enable it in browser settings and try again.')
        } else {
          setLocationError('Could not get location: ' + err.message + '. Try again or click Deny.')
        }
      },
      { timeout: 10000, enableHighAccuracy: false }
    )
  }

  function handleLocationDeny() {
    setShowLocationPrompt(false)
    const tid = locationTaskId
    setLocationTaskId(null)
    setLocationError(null)
    api.denyLocation(tid)
  }

  if (publicShareToken) {
    return (
      <PublicShareView
        token={publicShareToken}
        onExit={() => {
          window.history.replaceState({}, '', '/')
          setPublicShareToken(null)
        }}
      />
    )
  }

  return (
    <>
      {showLocationPrompt && <LocationPrompt onAllow={handleLocationAllow} onDeny={handleLocationDeny} error={locationError} />}
      <div style={{ display: authenticated ? '' : 'none' }}>
        <ModelBar
          modelStatus={modelStatus}
          modelTps={modelTps}
          tokenEstimate={tokenEstimate}
          contextCompressed={contextCompressed}
          rawTokenEstimate={rawTokenEstimate}
          maxContext={maxContext}
          onToggleSidebar={() => setSidebarOpen(o => !o)}
          username={username}
          onLogout={handleLogout}
          reminderCount={reminderCount}
          onToggleTasks={() => setShowTasks(o => !o)}
        />
        <div id="app-container">
          <Sidebar
            sessions={sessions}
            currentSessionId={currentSessionId}
            onSwitchSession={switchSession}
            onNewChat={newChat}
            onRenameSession={renameSession_}
            onDeleteSession={deleteSession_}
            onClose={closeSidebar}
            open={sidebarOpen}
          />
          <OverloadWarning overheated={overheated} gpuTemp={gpuTemp} ramEvacuating={ramEvacuating} />
          <ChatArea
            messages={messages}
            pendingMessages={pendingMessages}
            currentSessionId={currentSessionId}
            onImageOpen={setLightboxSrc}
            selectingRef={selectingRef}
            onPendingResolved={handlePendingResolved}
            onLocationNeeded={handleLocationNeeded}
          />
          <InputBar
            onSend={handleSend}
            hasPending={hasPendingForCurrent}
          />
        </div>
        <ImageLightbox src={lightboxSrc} onClose={closeLightbox} />
        {showTasks && <TaskPanel onClose={() => setShowTasks(false)} />}
      </div>
    </>
  )
}

function getStoredPending() {
  try {
    return JSON.parse(sessionStorage.getItem('opencode_pending')) || []
  } catch {
    return []
  }
}

function setStoredPending(arr) {
  try {
    sessionStorage.setItem('opencode_pending', JSON.stringify(arr))
  } catch { /* ignore */ }
}
</file>

<file path="server/features/images.py">
"""ComfyUI image generation and editing."""

import base64
import json
import os
import random
import time
import uuid

import requests

from server.features.state import M
from server.features.users import _safe_username


# LLM-selectable framing presets for generate_image. All values are divisible
# by 8 (latent-safe for EmptySD3LatentImage) and stay near the ~2 MP budget of
# the default 1920x1080, so VRAM usage and generation time are stable no matter
# which framing the model picks.
ASPECT_SIZES = {
    "landscape": (1920, 1080),
    "portrait": (1080, 1920),
    "square": (1440, 1440),
}


def _aspect_dims(aspect_ratio):
    return ASPECT_SIZES.get(aspect_ratio, ASPECT_SIZES["landscape"])


def _output_dir(user):
    return os.path.join(M.COMFYUI_OUTPUT, _safe_username(user))


def _input_dir(user):
    return os.path.join(M.COMFYUI_INPUT, _safe_username(user))


def _output_rel(target):
    if os.path.isabs(target):
        try:
            return os.path.relpath(target, M.COMFYUI_OUTPUT)
        except ValueError:
            return os.path.basename(target)
    return target


def _image_url_rel(url):
    marker = "/output/"
    if marker in url:
        return url.split(marker, 1)[-1]
    return os.path.basename(url)


def free_comfyui_vram():
    print("[comfyui] Freeing VRAM...")
    try:
        r = requests.post(
            f"{M.COMFYUI_URL}/free",
            json={"unload_models": True, "free_memory": True},
            timeout=30,
        )
        if r.status_code == 200:
            print("[comfyui] VRAM freed")
            return True
    except Exception as e:
        print(f"[comfyui] Free error: {e}")
    finally:
        time.sleep(10)
    return False


def generate_image(prompt, task_id, negative_prompt="", model="z_image", aspect_ratio="landscape"):
    print(f"\n[image] Generating image for task {task_id} with the prompt: {prompt}")
    M.set_status(task_id, "Freeing VRAM for image generation...")
    # ComfyUI renders on the GPU, so only the GPU llama-server is unloaded.
    # The CPU server (self-chat agents) keeps running untouched.
    M.unload_llama_model("gpu")

    width, height = _aspect_dims(aspect_ratio)

    user = M._task_user(task_id)
    gen_tag = str(uuid.uuid4())[:8]
    prefix = f"{_safe_username(user)}/gen_{gen_tag}_"
    cfg = M.IMAGE_MODELS.get(model, M.IMAGE_MODELS["z_image"])
    if model == "z_image":
        print("Chose Z-Image Turbo for image generation")
        workflow = {
            "62": {
                "class_type": "CLIPLoader",
                "inputs": {"clip_name": cfg["clip1"], "type": "lumina2"},
            },
            "63": {"class_type": "VAELoader", "inputs": {"vae_name": cfg["vae"]}},
            "66": {
                "class_type": "UNETLoader",
                "inputs": {"unet_name": cfg["unet"], "weight_dtype": "default"},
            },
            "67": {
                "class_type": "CLIPTextEncode",
                "inputs": {"text": prompt, "clip": ["62", 0]},
            },
            "68": {
                "class_type": "EmptySD3LatentImage",
                "inputs": {"width": width, "height": height, "batch_size": 1},
            },
            "69": {
                "class_type": "ModelSamplingAuraFlow",
                "inputs": {"shift": 3, "model": ["66", 0]},
            },
            "71": {
                "class_type": "CLIPTextEncode",
                "inputs": {"text": negative_prompt, "clip": ["62", 0]},
            },
            "70": {
                "class_type": "KSampler",
                "inputs": {
                    "seed": random.randint(0, 2**31),
                    "steps": 8,
                    "cfg": 1.0,
                    "sampler_name": "res_multistep",
                    "scheduler": "simple",
                    "denoise": 1.0,
                    "model": ["69", 0],
                    "positive": ["67", 0],
                    "negative": ["71", 0],
                    "latent_image": ["68", 0],
                },
            },
            "65": {
                "class_type": "VAEDecode",
                "inputs": {"samples": ["70", 0], "vae": ["63", 0]},
            },
            "9": {
                "class_type": "SaveImage",
                "inputs": {"filename_prefix": prefix, "images": ["65", 0]},
            },
        }
    elif model == "sd3_5_medium":
        print("Chose SD 3.5 for image generation")
        workflow = {
            "1": {"class_type": "UnetLoaderGGUF", "inputs": {"unet_name": cfg["unet"]}},
            "2": {
                "class_type": "TripleCLIPLoaderGGUF",
                "inputs": {
                    "clip_name1": cfg["clip1"],
                    "clip_name2": cfg["clip2"],
                    "clip_name3": cfg["t5"],
                    "type": "sd3",
                },
            },
            "3": {
                "class_type": "CLIPTextEncode",
                "inputs": {"text": prompt, "clip": ["2", 0]},
            },
            "4": {
                "class_type": "CLIPTextEncode",
                "inputs": {"text": negative_prompt, "clip": ["2", 0]},
            },
            "5": {
                "class_type": "EmptySD3LatentImage",
                "inputs": {"width": width, "height": height, "batch_size": 1},
            },
            "6": {
                "class_type": "KSampler",
                "inputs": {
                    "seed": random.randint(0, 2**31),
                    "steps": 20,  # Recommended steps for SD 3.5 Medium
                    "cfg": 4.5,  # Recommended CFG range for SD 3.5 Medium: 3.5 to 5.0
                    "sampler_name": "euler",
                    "scheduler": "sgm_uniform",
                    "denoise": 1.0,
                    "model": ["1", 0],
                    "positive": ["3", 0],
                    "negative": ["4", 0],
                    "latent_image": ["5", 0],
                },
            },
            "7": {"class_type": "VAELoader", "inputs": {"vae_name": cfg["vae"]}},
            "8": {
                "class_type": "VAEDecode",
                "inputs": {"samples": ["6", 0], "vae": ["7", 0]},
            },
            "9": {
                "class_type": "SaveImage",
                "inputs": {"filename_prefix": prefix, "images": ["8", 0]},
            },
        }
    else:
        print("No Image Model Selected Perfectly")

    with M._data_lock:
        M.model_status = "image_active"
        M.tasks[task_id]["gen_prompt"] = prompt
        M.tasks[task_id]["_image_model"] = model
        M.tasks[task_id]["negative_prompt"] = negative_prompt
    M.ensure_comfyui_running()
    p_short = prompt[:200] + ("..." if len(prompt) > 200 else "")
    M.set_status(task_id, f"Generating image ({model})... Prompt: {p_short}")
    try:
        r = requests.post(
            f"{M.COMFYUI_URL}/prompt", json={"prompt": workflow}, timeout=120
        )
        data = r.json()

        if "error" in data:
            result = json.dumps({"error": f"ComfyUI: {data['error']}"})
        else:
            prompt_id = data["prompt_id"]
            found_file = None
            for _ in range(300):
                time.sleep(1)
                try:
                    hr = requests.get(f"{M.COMFYUI_URL}/history/{prompt_id}", timeout=10)
                    hist = hr.json()

                    if prompt_id in hist:
                        outputs = hist[prompt_id].get("outputs", {})
                        for node_id, node_out in outputs.items():
                            for img in node_out.get("images", []):
                                fname = img["filename"]
                                fpath = os.path.join(
                                    M.COMFYUI_OUTPUT, img.get("subfolder", ""), fname
                                )
                                found_file = fpath
                                break
                        if found_file:
                            break
                except Exception:
                    pass
            if found_file:
                with M._data_lock:
                    cancelled = bool(M.tasks.get(task_id, {}).get("status") == "cancelled")
                if cancelled:
                    try:
                        if os.path.exists(found_file):
                            os.remove(found_file)
                            print(f"[image] Deleted orphaned image for cancelled task {task_id}: {found_file}")
                    except OSError:
                        pass
                    result = json.dumps({"error": "Cancelled — session was deleted"})
                else:
                    M.tasks[task_id]["image_file"] = M._output_rel(found_file)
                    M.set_status(task_id, f"Image saved as {found_file}")
                    print(f"[generate_image] SUCCESS: {found_file}")  # DEBUG
                    result = json.dumps(
                        {
                            "prompt_id": prompt_id,
                            "file": found_file,
                            "rel": M._output_rel(found_file),
                        }
                    )
            else:
                print(f"[generate_image] TIMEOUT for task {task_id} after 300s")  # DEBUG
                result = json.dumps({"error": "Image generation timeout"})
    except Exception as e:
        result = json.dumps({"error": str(e)})
    finally:
        M.set_status(task_id, "Freeing image generation VRAM...")
        M.free_comfyui_vram()
        M.set_status(task_id, "Loading chat model...")
        M.load_llama_model("gpu")
    return result


def edit_image(
    prompt,
    task_id,
    image_b64,
    negative_prompt="",
    denoise=0.4,
    model="z_image",
    sid=None,
):
    print("Image edit called with denoise", denoise)
    user = M._task_user(task_id)
    if not image_b64 and sid:
        with M._data_lock:
            msgs = list(M.sessions.get(sid, []))
        print(f"[edit_image] Scanning {len(msgs)} session messages for image sources")

        for msg in reversed(msgs):
            # 1. Check generated image URL attribute (_image_url)
            url = (msg.get("_image_url") or "").strip()
            if url:
                fname = os.path.join(M.IMG_PATH, M._image_url_rel(url))
                fpath = fname
                print(
                    f"[edit_image] Checking _image_url path={fpath} exists={os.path.exists(fpath)}"
                )
                if os.path.exists(fpath):
                    with open(fpath, "rb") as f:
                        image_b64 = base64.b64encode(f.read()).decode()
                    break

            # 2. Check user-uploaded images stored in the message's content array
            content = msg.get("content")
            if isinstance(content, list):
                for part in reversed(content):
                    if isinstance(part, dict) and part.get("type") == "image_url":
                        img_url = part.get("image_url", {}).get("url", "")
                        if img_url.startswith("data:image"):
                            # Extracted base64 string directly from user upload
                            image_b64 = img_url.split(",", 1)[-1]
                            print(
                                "[edit_image] Extracted base64 image from user message content"
                            )
                            break
                        fpath = M.resolve_image_path(img_url)
                        if fpath and os.path.exists(fpath):
                            with open(fpath, "rb") as f:
                                image_b64 = base64.b64encode(f.read()).decode()
                            print(
                                f"[edit_image] Loaded image from {img_url} ({len(image_b64)} bytes base64)"
                            )
                            break
                if image_b64:
                    break

    if not image_b64:
        print("[edit_image] FAILED to find an image to edit")
        return json.dumps({"error": "No image provided for editing."})

    print(
        f"[edit_image] Found image ({len(image_b64)} bytes base64), proceeding with edit"
    )

    print(f"\n[image_edit] Editing image for task {task_id} with prompt: {prompt}")
    M.set_status(task_id, "Freeing VRAM for image editing...")
    # ComfyUI renders on the GPU, so only the GPU llama-server is unloaded.
    # The CPU server (self-chat agents) keeps running untouched.
    M.unload_llama_model("gpu")

    gen_tag = str(uuid.uuid4())[:8]
    prefix = f"{_safe_username(user)}/edit_{gen_tag}_"
    input_filename = f"{_safe_username(user)}/input_{gen_tag}.png"

    input_dir = M.COMFYUI_INPUT
    os.makedirs(os.path.dirname(os.path.join(input_dir, input_filename)), exist_ok=True)
    input_filepath = os.path.join(input_dir, input_filename)

    with open(input_filepath, "wb") as f:
        f.write(base64.b64decode(image_b64))

    cfg = M.IMAGE_MODELS.get(model, M.IMAGE_MODELS["z_image"])

    workflow = {
        "62": {
            "class_type": "CLIPLoader",
            "inputs": {"clip_name": cfg["clip1"], "type": "lumina2"},
        },
        "63": {"class_type": "VAELoader", "inputs": {"vae_name": cfg["vae"]}},
        "66": {
            "class_type": "UNETLoader",
            "inputs": {"unet_name": cfg["unet"], "weight_dtype": "default"},
        },
        "67": {
            "class_type": "CLIPTextEncode",
            "inputs": {"text": prompt, "clip": ["62", 0]},
        },
        "71": {
            "class_type": "CLIPTextEncode",
            "inputs": {"text": negative_prompt, "clip": ["62", 0]},
        },
        "69": {
            "class_type": "ModelSamplingAuraFlow",
            "inputs": {"shift": 3, "model": ["66", 0]},
        },
        "5_load": {"class_type": "LoadImage", "inputs": {"image": input_filename}},
        "5_scale": {
            "class_type": "ImageScaleToTotalPixels",
            "inputs": {
                "image": ["5_load", 0],
                "megapixels": 1.049,  # ~1024x1024
                "upscale_method": "bicubic",
                "resolution_steps": 1,
            },
        },
        # Standard VAEEncode instead of VAEEncodeForInpaint
        "5_encode": {
            "class_type": "VAEEncode",
            "inputs": {"pixels": ["5_scale", 0], "vae": ["63", 0]},
        },
        "70": {
            "class_type": "KSampler",
            "inputs": {
                "seed": random.randint(0, 2**31),
                "steps": 8,
                "cfg": 1.0,
                "sampler_name": "res_multistep",
                "scheduler": "simple",
                "denoise": float(denoise),  # Dynamically controls edit depth
                "model": ["69", 0],
                "positive": ["67", 0],
                "negative": ["71", 0],
                "latent_image": ["5_encode", 0],
            },
        },
        "65": {
            "class_type": "VAEDecode",
            "inputs": {"samples": ["70", 0], "vae": ["63", 0]},
        },
        "9": {
            "class_type": "SaveImage",
            "inputs": {"filename_prefix": prefix, "images": ["65", 0]},
        },
    }
    with M._data_lock:
        M.model_status = "image_active"
        M.tasks[task_id]["gen_prompt"] = prompt
        M.tasks[task_id]["_image_model"] = model
        M.tasks[task_id]["negative_prompt"] = negative_prompt

    M.ensure_comfyui_running()
    M.set_status(task_id, f"Editing image ({model})... Prompt: {prompt[:150]}")

    try:
        r = requests.post(
            f"{M.COMFYUI_URL}/prompt", json={"prompt": workflow}, timeout=120
        )
        data = r.json()

        if "error" in data:
            result = json.dumps({"error": f"ComfyUI: {data['error']}"})
        else:
            prompt_id = data["prompt_id"]
            found_file = None
            for _ in range(300):
                time.sleep(1)
                try:
                    hr = requests.get(f"{M.COMFYUI_URL}/history/{prompt_id}", timeout=10)
                    hist = hr.json()
                    if prompt_id in hist:
                        outputs = hist[prompt_id].get("outputs", {})
                        for node_id, node_out in outputs.items():
                            for img in node_out.get("images", []):
                                fname = img["filename"]
                                found_file = os.path.join(
                                    M.IMG_PATH, img.get("subfolder", ""), fname
                                )
                                break
                        if found_file:
                            break
                except Exception:
                    pass

            if found_file:
                with M._data_lock:
                    cancelled = bool(M.tasks.get(task_id, {}).get("status") == "cancelled")
                if cancelled:
                    try:
                        if os.path.exists(found_file):
                            os.remove(found_file)
                            print(f"[image] Deleted orphaned edited image for cancelled task {task_id}: {found_file}")
                    except OSError:
                        pass
                    result = json.dumps({"error": "Cancelled — session was deleted"})
                else:
                    M.tasks[task_id]["image_file"] = M._output_rel(found_file)
                    M.set_status(task_id, f"Edited image saved as {found_file}")
                    result = json.dumps(
                        {
                            "prompt_id": prompt_id,
                            "file": found_file,
                            "rel": M._output_rel(found_file),
                        }
                    )
            else:
                result = json.dumps({"error": "Image editing timeout"})
    except Exception as e:
        result = json.dumps({"error": str(e)})
    finally:
        if os.path.exists(input_filepath):
            try:
                os.remove(input_filepath)
                print(f"[edit_image] Cleaned up input file: {input_filepath}")
            except Exception as e:
                print(f"[edit_image] Failed to cleanup input file: {e}")
        M.set_status(task_id, "Freeing image generation VRAM...")
        M.free_comfyui_vram()
        M.set_status(task_id, "Loading chat model...")
        M.load_llama_model("gpu")

    return result


def _enqueue_image_job(task_id, sid, tool_name, args, tc, round_num, tool_index):
    """Queue an image generation/edit job for the single image worker thread.

    Image work is serialized so the VRAM choreography (llama unload / ComfyUI
    / free / reload) and the ``image_active`` model status never race, even when
    CPU and GPU chat lanes process tasks concurrently. The job carries its
    originating session so the finished image lands in the right conversation.
    """
    with M._data_lock:
        image_b64 = M.tasks.get(task_id, {}).get("_original_image")
    M.set_status(task_id, "Queued for image generation...")
    M._image_queue.put(
        {
            "task_id": task_id,
            "sid": sid,
            "tool_name": tool_name,
            "args": args,
            "tc_id": tc["id"],
            "round": round_num,
            "tool_index": tool_index,
            "image_b64": image_b64,
        }
    )
    print(f"[image_worker] Queued {tool_name} for task {task_id} (sid {sid})")


def _run_generate_image(task_id, args):
    result = M.generate_image(
        prompt=args.get("prompt", ""),
        task_id=task_id,
        negative_prompt=args.get("negative_prompt", ""),
        model=args.get("model") or "z_image",
        aspect_ratio=args.get("aspect_ratio") or "landscape",
    )
    res_data = json.loads(result)
    if "file" in res_data:
        rel = res_data.get("rel") or os.path.basename(res_data["file"])
        image_url = f"/output/{rel}"
        image_model_s = args.get("model") or "z_image"
        with M._data_lock:
            t = M.tasks.get(task_id)
            if t:
                t.setdefault("_tools_used", []).append("generate_image")
                t["image_file"] = rel
                t["gen_prompt"] = args.get("prompt", "")
                t["_image_model"] = image_model_s
        return json.dumps(
            {
                "image_url": image_url,
                "prompt": args.get("prompt", ""),
                "model": image_model_s,
            }
        )
    return result


def _run_edit_image(task_id, sid, args, image_b64):
    result = M.edit_image(
        prompt=args.get("prompt", ""),
        task_id=task_id,
        image_b64=image_b64,
        negative_prompt=args.get("negative_prompt", ""),
        denoise=args.get("denoise", 0.4),
        model="z_image",
        sid=sid,
    )
    res_data = json.loads(result)
    if "file" in res_data:
        rel = res_data.get("rel") or os.path.basename(res_data["file"])
        image_url = f"/output/{rel}"
        with M._data_lock:
            t = M.tasks.get(task_id)
            if t:
                t.setdefault("_tools_used", []).append("edit_image")
                t["image_file"] = rel
                t["gen_prompt"] = args.get("prompt", "")
                t["_image_model"] = None
        return json.dumps(
            {
                "image_url": image_url,
                "prompt": args.get("prompt", ""),
                "model": None,
            }
        )
    return result


def _image_worker():
    """Run one image job at a time from the image queue.

    On completion the worker posts the same ``tool_ok`` event the tool worker
    would have, so the event loop, pending-tool counting and session attachment
    are unchanged. Matching to the originating session is preserved through the
    job's ``sid`` and the task-keyed ``image_file`` stored on ``tasks[task_id]``.
    """
    while True:
        job = M._image_queue.get()
        if job.get("tool_name") == "__shutdown__":
            break
        task_id = job["task_id"]
        with M._data_lock:
            t = M.tasks.get(task_id)
            if t is None or t.get("status") in ("cancelled", "error"):
                continue
        sid = job["sid"]
        tool_name = job["tool_name"]
        args = job["args"]
        try:
            if tool_name == "generate_image":
                result = _run_generate_image(task_id, args)
                print("Waiting 5s for GPU to cool down")
                time.sleep(5)
            elif tool_name == "edit_image":
                result = _run_edit_image(task_id, sid, args, job.get("image_b64"))
                print("Waiting 5s for GPU to cool down")
                time.sleep(5)
            else:
                result = json.dumps({"error": f"Unknown image tool: {tool_name}"})
        except Exception as e:
            print(f"[image_worker] {tool_name} crashed for task {task_id}: {e}")
            result = json.dumps({"error": f"Tool {tool_name} failed: {e}"})
        M._event_post(
            "tool_ok",
            task_id,
            tc_id=job["tc_id"],
            result=result,
            sid=sid,
            round=job["round"],
            tool_index=job["tool_index"],
        )
</file>

<file path="server/features/monitoring.py">
"""Health monitoring, server lifecycle and the background maintenance loops.

Two llama-server processes run concurrently on separate ports:

* the **GPU** server on ``LLAMA_BASE`` (8081) for interactive chat UI users, and
* the **CPU** server on ``LLAMA_BASE_CPU`` (8079) for automated self-chat
  agents.

Each is started, killed, health-checked and idle-unloaded independently so an
agent run never disturbs interactive users (and vice versa).
"""

import os
import subprocess
import time
from datetime import datetime

import requests

from server.features.state import M

_LLAMA_PORTS = {"gpu": "8081", "cpu": "8079"}


def model_status_snapshot():
    # The UI reports the interactive (GPU) server's state.
    with M._data_lock:
        return {
            "model": M.model_status,
            "predicted_per_second": M._last_tps,
            "overheated": M._overheated,
            "gpu_temp": M._gpu_temp,
            "ram_evacuating": M._ram_evacuating,
        }


def get_gpu_temp():
    try:
        r = subprocess.run(
            ["nvidia-smi", "--query-gpu=temperature.gpu", "--format=csv,noheader"],
            capture_output=True,
            text=True,
            timeout=5,
        )
        return int(r.stdout.strip())
    except Exception:
        return None


def get_ram_usage():
    try:
        r = subprocess.run(["free", "-m"], capture_output=True, text=True, timeout=5)
        lines = r.stdout.strip().split("\n")
        parts = lines[1].split()
        total = int(parts[1])
        available = int(parts[6])
        return (total - available) / total * 100
    except Exception:
        return None


def kill_llama_server(mode=None):
    """Kill llama-server process(es).

    ``mode`` is ``"gpu"`` (port 8081), ``"cpu"`` (port 8079) or ``None`` to
    kill both servers at once (emergency RAM evacuation, full restart).
    """
    if mode is None:
        subprocess.run(["pkill", "-f", "llama-server"], capture_output=True)
        time.sleep(1)
        subprocess.run(["pkill", "-9", "-f", "llama-server"], capture_output=True)
        return
    port = _LLAMA_PORTS[mode]
    pattern = f"llama-server.*--port {port}"
    subprocess.run(["pkill", "-f", pattern], capture_output=True)
    time.sleep(1)
    subprocess.run(["pkill", "-9", "-f", pattern], capture_output=True)


def kill_comfyui():
    subprocess.run(["pkill", "-f", "main.py.*lowvram"], capture_output=True)


def _start_llama_process(args, mode="gpu"):
    """Launch a llama-server with the given argument list and wait for health."""
    base = M.LLAMA_BASE_CPU if mode == "cpu" else M.LLAMA_BASE
    log_dir = os.path.expanduser("~/local-ai-files")
    llm_log = open(os.path.join(log_dir, f"{mode}-llama-server.log"), "a")
    subprocess.Popen(
        [M.LLAMA_SERVER_PATH] + args,
        stdout=llm_log,
        stderr=llm_log,
        start_new_session=True,
    )
    deadline = time.time() + 120
    while time.time() < deadline:
        time.sleep(2)
        try:
            r = requests.get(f"{base}/health", timeout=3)
            if r.status_code == 200:
                print(f"[restart] llama-server ({mode}) healthy on {base}")
                return True
        except Exception:
            pass
    print(f"[restart] llama-server ({mode}) did not respond within 2 minutes — killing")
    M.kill_llama_server(mode)
    return False


def restart_llama_server(mode):
    """Restart the llama-server for ``mode`` (``"gpu"`` or ``"cpu"``) using its
    own argument set and port, leaving the other server untouched."""
    print(f"[llama] Restarting llama-server ({mode})")
    M.kill_llama_server(mode)
    time.sleep(1)
    with M._data_lock:
        if mode == "cpu":
            M._cpu_model_status = "unloaded"
        else:
            M.model_status = "unloaded"
    args = M.LLAMA_SERVER_ARGS if mode == "gpu" else M.LLAMA_SERVER_ARGS_CPU
    _start_llama_process(args, mode)


def ensure_llama_server(mode):
    """Make sure the llama-server for ``mode`` is running, starting it if not."""
    base = M.LLAMA_BASE_CPU if mode == "cpu" else M.LLAMA_BASE
    if M.is_llama_alive(base):
        return
    print(f"[llama] {mode} llama-server not reachable — starting...")
    restart_llama_server(mode)


def _ensure_llama_server_for_task(task_id):
    """Make sure the llama-server the task's author needs is running.

    Tasks posted by agent users (self-chat: editor, moderator, ...) run on the
    CPU server; tasks from interactive users use the GPU server.
    """
    with M._data_lock:
        if task_id not in M.tasks:
            return
    mode = M.task_mode(task_id)
    M.ensure_llama_server(mode)


def _cpu_lane_needed():
    """True if the CPU self-chat lane has (or is about to have) work.

    The CPU llama-server is only started when a self-chat agent is registered
    or an agent task is queued/running on the cpu lane. This keeps the machine
    from booting a second llama-server that nothing ever uses. Under the
    test-time ``FORCE_GPU_LANE`` flag the CPU lane is never needed at all.
    """
    if M.FORCE_GPU_LANE:
        return False
    with M._data_lock:
        if M._agent_users:
            return True
    with M._queue_locks["cpu"]:
        return len(M._task_queues["cpu"]) > 0 or M._current_task_ids["cpu"] is not None


def restart_servers():
    print("Restarting servers")
    M.kill_llama_server()
    M.kill_comfyui()
    time.sleep(1)
    log_dir = os.path.expanduser("~/local-ai-files")
    comfy_log = open(os.path.join(log_dir, "comfyui.log"), "a")
    subprocess.Popen(
        [
            os.path.join(M.VENV_PYTHON),
            "main.py",
            "--output-directory",
            M.COMFYUI_OUTPUT,
            "--input-directory",
            M.COMFYUI_INPUT,
            "--lowvram",
        ],
        cwd=M.COMFYUI_DIR,
        stdout=comfy_log,
        stderr=comfy_log,
        start_new_session=True,
    )
    with M._data_lock:
        M.model_status = "unloaded"
        M._cpu_model_status = "unloaded"
    _start_llama_process(M.LLAMA_SERVER_ARGS, "gpu")
    # The CPU self-chat server only comes up on demand (first agent task).
    if _cpu_lane_needed():
        _start_llama_process(M.LLAMA_SERVER_ARGS_CPU, "cpu")
    else:
        print("[llama] Skipping CPU llama-server start (no agent lane activity)")


def ensure_comfyui_running():
    try:
        r = requests.get(f"{M.COMFYUI_URL}/prompt", timeout=3)
        if r.status_code < 500:
            return
    except Exception:
        pass
    print("[comfyui] Not reachable — starting...")
    M.kill_comfyui()
    time.sleep(1)
    log_dir = os.path.expanduser("~/local-ai")
    comfy_log = open(os.path.join(log_dir, "comfyui.log"), "a")
    subprocess.Popen(
        [
            os.path.join(M.VENV_PYTHON),
            "main.py",
            "--output-directory",
            M.COMFYUI_OUTPUT,
            "--input-directory",
            M.COMFYUI_INPUT,
            "--lowvram",
        ],
        cwd=M.COMFYUI_DIR,
        stdout=comfy_log,
        stderr=comfy_log,
        start_new_session=True,
    )
    deadline = time.time() + 120
    while time.time() < deadline:
        time.sleep(2)
        try:
            r = requests.get(f"{M.COMFYUI_URL}/prompt", timeout=3)
            if r.status_code < 500:
                print("[comfyui] Healthy")
                return
        except Exception:
            pass
    print("[comfyui] Did not respond within 2 minutes")


def _idle_unload_loop():
    while True:
        time.sleep(10)

        # Each llama-server unloads independently once its own LANE has been
        # idle for > 300s. This is checked per-lane (not combined) so a busy
        # CPU self-chat agent can't keep the idle GPU model pinned in VRAM,
        # and vice versa.
        for mode in ("gpu", "cpu"):
            with M._queue_locks[mode]:
                queue_active = len(M._task_queues[mode]) > 0 or M._current_task_ids[mode] is not None
            with M._data_lock:
                ms = M._cpu_model_status if mode == "cpu" else M.model_status
                lu = M._cpu_last_llm_use if mode == "cpu" else M._last_llm_use
            if ms == "chat_loaded" and (time.time() - lu > 300) and not queue_active:
                print(f"[idle] No {mode} LLM activity for 300s, releasing model weights...")
                M.unload_llama_model(mode)


def _reminder_loop():
    while True:
        try:
            now = datetime.now().isoformat()
            due = M._db_fetch("SELECT * FROM tasks WHERE reminder_at IS NOT NULL AND reminder_at <= ? AND reminded=0 AND status NOT IN ('completed','cancelled')", (now,))
            for task in due:
                print(f"[reminder] Task '{task['title']}'. User: {task['user_id']}")
                M._db_run("UPDATE tasks SET reminded=1 WHERE id=?", (task["id"],))
        except Exception as e:
            print(f"[reminder] Error: {e}")
        time.sleep(43200)


def _evacuate_ram():
    M._ram_evacuating = True
    print("[ram] Emergency RAM evacuation")
    # RAM pressure is whole-box, so both lanes (GPU/UI and CPU/agent) get
    # their in-flight task requeued to the front of their own lane.
    for mode in ("gpu", "cpu"):
        with M._queue_locks[mode]:
            tid = M._current_task_ids[mode]
            if tid:
                with M._data_lock:
                    t = M.tasks.get(tid)
                    if t and t.get("status") not in ("done", "error"):
                        entry = {
                            "task_id": tid,
                            "session_id": t.get("session_id", ""),
                            "message": t.get("_original_message", ""),
                            "image": t.get("_original_image"),
                            "user": t.get("_user", ""),
                            "client_timestamp": t.get("_client_timestamp"),
                        }
                        M._task_queues[mode].insert(0, entry)
                        t["status"] = "error"
                        t["error"] = "Server ran out of RAM — requeued"
                        t["_ram_evacuating"] = True
                        print(f"[ram] Requeued {mode} task {tid} to front of its lane")
    M.kill_llama_server()
    M.kill_comfyui()
    print("[ram] Killed llama-server and ComfyUI")
    while True:
        time.sleep(5)
        ram = M.get_ram_usage()
        if ram is not None and ram <= M.RAM_RESUME_THRESHOLD:
            print(f"[ram] RAM {ram:.0f}% ≤ {M.RAM_RESUME_THRESHOLD}%, restarting servers")
            break
    M.restart_servers()
    M._ram_evacuating = False


def _thermal_monitor():
    while True:
        time.sleep(10)
        temp = M.get_gpu_temp()
        with M._data_lock:
            M._gpu_temp = temp
            if temp is not None and temp >= M.TEMP_THRESHOLD_ON:
                if not M._overheated:
                    print(
                        f"[thermal] GPU {temp}°C >= {M.TEMP_THRESHOLD_ON}°C, OVERHEATED"
                    )
                    M._overheated = True
            elif M._overheated and (temp is None or temp <= M.TEMP_THRESHOLD_OFF):
                print(f"[thermal] GPU {temp}°C <= {M.TEMP_THRESHOLD_OFF}°C, resumed")
                M._overheated = False

        if M._overheated:
            # Only the GPU lane's business matters here — unloading the GPU
            # chat model / freeing ComfyUI VRAM should not be held up by an
            # unrelated self-chat agent task running on the CPU lane.
            with M._queue_locks["gpu"]:
                busy = M._current_task_ids["gpu"] is not None
            if not busy:
                with M._data_lock:
                    ms = M.model_status
                if ms == "chat_loaded":
                    print("[thermal] Overheated — unloading GPU chat model")
                    M.unload_llama_model("gpu")
                elif ms == "image_active":
                    print("[thermal] Overheated — freeing ComfyUI VRAM")
                    M.free_comfyui_vram()

        if not M._ram_evacuating:
            ram = M.get_ram_usage()
            if ram is not None and ram >= M.RAM_EVAC_THRESHOLD:
                print(f"[ram] RAM usage {ram:.0f}% >= {M.RAM_EVAC_THRESHOLD}%")
                M._evacuate_ram()


def _get_current_ipv6():
    """Get this machine's stable global IPv6 address."""
    try:
        iface = subprocess.check_output(
            "ip -6 route show default | awk '{print $5; exit}'", shell=True, text=True
        ).strip()
        output = subprocess.check_output(
            f"ip -6 addr show {iface} scope global", shell=True, text=True
        )
        for line in output.splitlines():
            if "inet6" in line and "temporary" not in line:
                return line.split()[1].split("/")[0]
    except Exception as e:
        print(f"[ddns] Failed to get IPv6: {e}")
    return None


def _get_wifi_ipv4():
    """This machine's LAN IPv4 on the default (WiFi) interface."""
    try:
        iface = subprocess.check_output(
            "ip -4 route show default | awk '{print $5; exit}'", shell=True, text=True
        ).strip()
        output = subprocess.check_output(
            f"ip -4 addr show {iface} scope global", shell=True, text=True
        )
        for line in output.splitlines():
            line = line.strip()
            if line.startswith("inet ") and "secondary" not in line:
                return line.split()[1].split("/")[0]
    except Exception as e:
        print(f"[heartbeat] Failed to get WiFi IPv4: {e}")
    return None


_public_ipv4_cache = {"ip": None, "ts": 0.0}


def _get_public_ipv4():
    """Public WAN IPv4 as seen from the internet, cached for 5 minutes."""
    now = time.time()
    if _public_ipv4_cache["ip"] and now - _public_ipv4_cache["ts"] < 300:
        return _public_ipv4_cache["ip"]
    for url in ("https://api.ipify.org", "https://ifconfig.me/ip"):
        try:
            r = requests.get(url, timeout=5)
            ip = r.text.strip()
            if r.status_code == 200 and ip.count(".") == 3:
                _public_ipv4_cache.update(ip=ip, ts=now)
                return ip
        except Exception:
            pass
    print("[heartbeat] Could not determine public IPv4")
    return _public_ipv4_cache["ip"]


def _send_heartbeat():
    """POST this machine's addresses to the GCP receiver over the tunnel."""
    payload = {
        "ipv6": _get_current_ipv6(),
        "public_ipv4": _get_public_ipv4(),
        "wifi_ipv4": _get_wifi_ipv4(),
    }
    r = requests.post(M.HEARTBEAT_URL, json=payload, timeout=5)
    r.raise_for_status()
    return payload


def _ddns_enabled():
    """True when the GoDaddy API credentials are available in the environment."""
    return bool(M.GODADDY_API_KEY and M.GODADDY_API_SECRET)


def _get_current_ipv6():
    """Get this machine's stable global IPv6 address."""
    try:
        iface = subprocess.check_output(
            "ip -6 route show default | awk '{print $5; exit}'", shell=True, text=True
        ).strip()
        output = subprocess.check_output(
            f"ip -6 addr show {iface} scope global", shell=True, text=True
        )
        for line in output.splitlines():
            if "inet6" in line and "temporary" not in line:
                return line.split()[1].split("/")[0]
    except Exception as e:
        print(f"[ddns] Failed to get IPv6: {e}")
    return None


def _update_godaddy_aaaa(new_ip):
    url = f"https://api.godaddy.com/v1/domains/{M.DDNS_DOMAIN}/records/AAAA/{M.DDNS_SUBDOMAIN}"
    headers = {
        "Authorization": f"sso-key {M.GODADDY_API_KEY}:{M.GODADDY_API_SECRET}",
        "Content-Type": "application/json",
    }
    resp = requests.put(url, headers=headers, json=[{"data": new_ip, "ttl": 600}])
    if resp.status_code == 200:
        print(f"[ddns] GoDaddy AAAA updated to {new_ip}")
        return True
    else:
        print(f"[ddns] GoDaddy update failed ({resp.status_code}): {resp.text}")
        return False


_last_dns_check = 0
_last_known_ipv6 = None


def maybe_update_dns():
    """Call on every ConnectionManager tick — self-throttles to DDNS_CHECK_INTERVAL."""
    global _last_dns_check, _last_known_ipv6
    if not _ddns_enabled():
        return
    interval = M.DDNS_CHECK_INTERVAL or 300
    now = time.time()
    if now - _last_dns_check < interval:
        return  # not time yet, skip
    _last_dns_check = now
    current_ip = _get_current_ipv6()
    if not current_ip:
        return
    if current_ip != _last_known_ipv6:
        if _update_godaddy_aaaa(current_ip):
            _last_known_ipv6 = current_ip


def _connection_manager():
    while True:
        try:
            payload = _send_heartbeat()
            print(f"[+] heartbeat sent: {payload}")
        except Exception as e:
            print(f"[-] GCP unreachable ({M.HEARTBEAT_URL}): {e}")

        # Keep the GoDaddy AAAA record pointed at this machine's IPv6.
        maybe_update_dns()

        time.sleep(10)
</file>

<file path="local_cloud.sh">
#!/usr/bin/env bash
set -e

echo "=== 1. Setting permissions on external media drive ==="
# sudo chown -R www-data:www-data /mnt/wwn-0x50014ee2173893e0-part1/BackUp-Copy-2/
# sudo chmod -R 0750 /mnt/wwn-0x50014ee2173893e0-part1/BackUp-Copy-2/

echo "=== 2. Creating Dashboard HTML ==="
sudo mkdir -p /var/www/dashboard
cat << 'EOF' | sudo tee /var/www/dashboard/index.html > /dev/null
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Homeserver Dashboard</title>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0d1117; color: #c9d1d9; display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px; }
        .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; width: 100%; max-width: 900px; }
        .card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 24px; text-decoration: none; color: inherit; transition: transform 0.2s, border-color 0.2s; text-align: center; }
        .card:hover { transform: translateY(-4px); border-color: #58a6ff; }
        .icon { font-size: 40px; margin-bottom: 12px; }
        .title { font-size: 18px; font-weight: 600; color: #58a6ff; margin-bottom: 6px; }
        .desc { font-size: 13px; color: #8b949e; }
    </style>
</head>
<body>
    <div class="grid">
        <a href="/ai/" class="card">
            <div class="icon">🤖</div>
            <div class="title">Local AI</div>
            <div class="desc">/ai/</div>
        </a>
        <a href="/stories/" class="card">
            <div class="icon">📖</div>
            <div class="title">AI Generated Stories</div>
            <div class="desc">/stories/</div>
        </a>
        <a href="/search/" class="card">
            <div class="icon">🔍</div>
            <div class="title">Search Engine</div>
            <div class="desc">/search/</div>
        </a>
        <a href="/cloud/" class="card">
            <div class="icon">☁️</div>
            <div class="title">Nextcloud</div>
            <div class="desc">/cloud/</div>
        </a>
        <a href="/cloud/apps/files/files/136?dir=/Media/Public/E-books/" class="card">
            <div class="icon">📚</div>
            <div class="title">Books</div>
            <div class="desc">E-books</div>
        </a>
        <a href="/code/" class="card">
            <div class="icon">💻</div>
            <div class="title">Code Hoster</div>
            <div class="desc">/code/</div>
        </a>
        <a href="/track/" class="card">
            <div class="icon">📊</div>
            <div class="title">Request Tracker</div>
            <div class="desc">/track/</div>
        </a>
    </div>
</body>
</html>
EOF

sudo chown -R www-data:www-data /var/www/dashboard
sudo chmod -R 755 /var/www/dashboard

echo "=== 3. Writing Nginx Master Configuration ==="
cat << 'EOF' | sudo tee /etc/nginx/sites-available/homeserver > /dev/null
map $http_x_via_gcp $gcp_overlay {
    "true"  '<div id="gcp-overlay" style="position:fixed;top:0;left:0;width:100vw;height:100vh;background-color:rgba(239,68,68,0.05);border-top:3px solid rgba(239,68,68,0.6);pointer-events:none;z-index:999998;"></div>';
    default '';
}

map $http_user_agent $cloud_inject {
    "~*nextcloud-(android|ios|desktop)" '';
    default '$gcp_overlay<div id="global-nav-bar" style="position:absolute;bottom:16px;right:16px;z-index:999999;display:flex;gap:8px;background:rgba(22,27,34,0.95);padding:6px 12px;border-radius:20px;border:1px solid #30363d;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-family:sans-serif;font-size:13px;backdrop-filter:blur(4px);"><a href="/" style="color:#58a6ff;text-decoration:none;font-weight:600;">Home</a><span style="color:#484f58;">|</span><a href="/ai/" style="color:#c9d1d9;text-decoration:none;">AI</a><a href="/stories/" style="color:#c9d1d9;text-decoration:none;">Stories</a><a href="/code/" style="color:#c9d1d9;text-decoration:none;">Code</a><a href="/search/" style="color:#c9d1d9;text-decoration:none;">Search</a><a href="/cloud/" style="color:#c9d1d9;text-decoration:none;">Cloud</a><a href="/track/" style="color:#c9d1d9;text-decoration:none;">Track</a></div></body>';
}

upstream ak_outpost {
    server 127.0.0.1:9010;
}
upstream ak_server {
    server 127.0.0.1:9008;
}
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

log_format track escape=json '{"time":"$time_iso8601","gcp":"$http_x_via_gcp","ip":"$remote_addr","method":"$request_method","uri":"$request_uri","status":$status,"in":$request_length,"out":$bytes_sent,"ua":"$http_user_agent"}';

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name home.palashkantikundu.in;

    ssl_certificate     /etc/letsencrypt/live/home.palashkantikundu.in/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/home.palashkantikundu.in/privkey.pem;

    client_max_body_size 512M;
    client_body_buffer_size 128k;

    access_log /var/log/nginx/track.log track;

    # Expose Outpost traffic directly so browser auth flow and internal calls do not fail
    location /outpost.goauthentik.io/ {
        proxy_pass http://ak_outpost;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
    }

    # Internal auth checking block
    location /ak-auth-ai {
        internal;
        proxy_pass http://ak_outpost/outpost.goauthentik.io/auth/nginx;
        proxy_pass_request_body off;
        proxy_set_header Content-Length "";
        proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_no_cache 1;
        proxy_cache_bypass 1;
    }

    # Redirect unauthenticated users directly to Outpost start portal
    location @ak-sso-ai {
        internal;
        return 302 /outpost.goauthentik.io/start?rd=$scheme://$http_host$request_uri;
    }

    location / {
        root /var/www/dashboard;
        index index.html;

        sub_filter_once off;
        sub_filter_types text/html;
        sub_filter '</body>' '$gcp_overlay<div id="global-nav-bar" style="position:absolute;bottom:16px;right:16px;z-index:999999;display:flex;gap:8px;background:rgba(22,27,34,0.95);padding:6px 12px;border-radius:20px;border:1px solid #30363d;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-family:sans-serif;font-size:13px;backdrop-filter:blur(4px);"><a href="/" style="color:#58a6ff;text-decoration:none;font-weight:600;">Home</a><span style="color:#484f58;">|</span><a href="/ai/" style="color:#c9d1d9;text-decoration:none;">AI</a><a href="/stories/" style="color:#c9d1d9;text-decoration:none;">Stories</a><a href="/code/" style="color:#c9d1d9;text-decoration:none;">Code</a><a href="/search/" style="color:#c9d1d9;text-decoration:none;">Search</a><a href="/cloud/" style="color:#c9d1d9;text-decoration:none;">Cloud</a><a href="/track/" style="color:#c9d1d9;text-decoration:none;">Track</a></div></body>';
    }

    # Authentik SSO Core (matches both /sso and /sso/*)
    location /sso {
        proxy_pass http://ak_server;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-Host $host;
    }

    # 1. Local AI App
    location /ai/ {
        auth_request /ak-auth-ai;
        auth_request_set $authentik_username $upstream_http_x_authentik_username;
        auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
        auth_request_set $authentik_email $upstream_http_x_authentik_email;
        auth_request_set $authentik_name $upstream_http_x_authentik_name;
        auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
        error_page 401 = @ak-sso-ai;

        proxy_pass http://127.0.0.1:3001/;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Accept-Encoding "";
        proxy_set_header X-Authentik-Username $authentik_username;
        proxy_set_header X-Authentik-Groups $authentik_groups;
        proxy_set_header X-Authentik-Email $authentik_email;
        proxy_set_header X-Authentik-Name $authentik_name;
        proxy_set_header X-Authentik-UID $authentik_uid;

        sub_filter_once off;
        sub_filter_types text/html;
        # Global nav pill injected into the Local AI page. Styling lives in a
        # <style> block (not inline) so it can go responsive: on desktop it
        # floats at the top-right as before; on phones (<=600px) that spot is
        # ON TOP of the Local AI busy/model header (#model-bar is fixed,
        # 48px tall, full-width there), so it drops to just BELOW the header
        # band instead and shrinks/scrolls horizontally.
        sub_filter '</body>' '$gcp_overlay<style>#global-nav-bar{position:absolute;z-index:999999;display:flex;gap:8px;background:rgba(22,27,34,0.95);padding:6px 12px;border-radius:20px;border:1px solid #30363d;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-family:sans-serif;font-size:13px;backdrop-filter:blur(4px)}#global-nav-bar a{white-space:nowrap}@media(min-width:601px){#global-nav-bar{top:8px;right:80px}}@media(max-width:600px){#global-nav-bar{top:56px;right:8px;padding:4px 10px;font-size:11px;gap:6px;max-width:calc(100vw - 16px);overflow-x:auto}}</style><div id="global-nav-bar"><a href="/" style="color:#58a6ff;text-decoration:none;font-weight:600;">Home</a><span style="color:#484f58;">|</span><a href="/ai/" style="color:#c9d1d9;text-decoration:none;">AI</a><a href="/stories/" style="color:#c9d1d9;text-decoration:none;">Stories</a><a href="/code/" style="color:#c9d1d9;text-decoration:none;">Code</a><a href="/search/" style="color:#c9d1d9;text-decoration:none;">Search</a><a href="/cloud/" style="color:#c9d1d9;text-decoration:none;">Cloud</a><a href="/track/" style="color:#c9d1d9;text-decoration:none;">Track</a></div></body>';
    }

    location ^~ /api/public/ {
        proxy_pass http://127.0.0.1:3001/api/public/;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
    }

    location /api/ {
        auth_request /ak-auth-ai;
        auth_request_set $authentik_username $upstream_http_x_authentik_username;
        auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
        auth_request_set $authentik_email $upstream_http_x_authentik_email;
        auth_request_set $authentik_name $upstream_http_x_authentik_name;
        auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
        error_page 401 = @ak-sso-ai;

        proxy_pass http://127.0.0.1:3001/api/;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Authentik-Username $authentik_username;
        proxy_set_header X-Authentik-Groups $authentik_groups;
        proxy_set_header X-Authentik-Email $authentik_email;
        proxy_set_header X-Authentik-Name $authentik_name;
        proxy_set_header X-Authentik-UID $authentik_uid;
    }

    # 2. Chat Stories App
    location /stories/ {
        auth_request /ak-auth-ai;
        auth_request_set $authentik_username $upstream_http_x_authentik_username;
        auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
        auth_request_set $authentik_email $upstream_http_x_authentik_email;
        auth_request_set $authentik_name $upstream_http_x_authentik_name;
        auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
        error_page 401 = @ak-sso-ai;

        proxy_pass http://127.0.0.1:3002/;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Accept-Encoding "";
        proxy_set_header X-Authentik-Username $authentik_username;
        proxy_set_header X-Authentik-Groups $authentik_groups;
        proxy_set_header X-Authentik-Email $authentik_email;
        proxy_set_header X-Authentik-Name $authentik_name;
        proxy_set_header X-Authentik-UID $authentik_uid;

        sub_filter_once off;
        sub_filter_types text/html;
        sub_filter '</body>' '$gcp_overlay<div id="global-nav-bar" style="position:absolute;bottom:16px;right:16px;z-index:999999;display:flex;gap:8px;background:rgba(22,27,34,0.95);padding:6px 12px;border-radius:20px;border:1px solid #30363d;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-family:sans-serif;font-size:13px;backdrop-filter:blur(4px);"><a href="/" style="color:#58a6ff;text-decoration:none;font-weight:600;">Home</a><span style="color:#484f58;">|</span><a href="/ai/" style="color:#c9d1d9;text-decoration:none;">AI</a><a href="/stories/" style="color:#c9d1d9;text-decoration:none;">Stories</a><a href="/code/" style="color:#c9d1d9;text-decoration:none;">Code</a><a href="/search/" style="color:#c9d1d9;text-decoration:none;">Search</a><a href="/cloud/" style="color:#c9d1d9;text-decoration:none;">Cloud</a><a href="/track/" style="color:#c9d1d9;text-decoration:none;">Track</a></div></body>';
    }

    location /story/ {
        auth_request /ak-auth-ai;
        auth_request_set $authentik_username $upstream_http_x_authentik_username;
        auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
        auth_request_set $authentik_email $upstream_http_x_authentik_email;
        auth_request_set $authentik_name $upstream_http_x_authentik_name;
        auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
        error_page 401 = @ak-sso-ai;

        proxy_pass http://127.0.0.1:3002/story/;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Authentik-Username $authentik_username;
        proxy_set_header X-Authentik-Groups $authentik_groups;
        proxy_set_header X-Authentik-Email $authentik_email;
        proxy_set_header X-Authentik-Name $authentik_name;
        proxy_set_header X-Authentik-UID $authentik_uid;

        sub_filter_once off;
        sub_filter_types text/html;
        sub_filter '</body>' '$gcp_overlay<div id="global-nav-bar" style="position:absolute;bottom:16px;right:16px;z-index:999999;display:flex;gap:8px;background:rgba(22,27,34,0.95);padding:6px 12px;border-radius:20px;border:1px solid #30363d;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-family:sans-serif;font-size:13px;backdrop-filter:blur(4px);"><a href="/" style="color:#58a6ff;text-decoration:none;font-weight:600;">Home</a><span style="color:#484f58;">|</span><a href="/ai/" style="color:#c9d1d9;text-decoration:none;">AI</a><a href="/stories/" style="color:#c9d1d9;text-decoration:none;">Stories</a><a href="/code/" style="color:#c9d1d9;text-decoration:none;">Code</a><a href="/search/" style="color:#c9d1d9;text-decoration:none;">Search</a><a href="/cloud/" style="color:#c9d1d9;text-decoration:none;">Cloud</a><a href="/track/" style="color:#c9d1d9;text-decoration:none;">Track</a></div></body>';
    }

    location /media/ {
        auth_request /ak-auth-ai;
        auth_request_set $authentik_username $upstream_http_x_authentik_username;
        auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
        auth_request_set $authentik_email $upstream_http_x_authentik_email;
        auth_request_set $authentik_name $upstream_http_x_authentik_name;
        auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
        error_page 401 = @ak-sso-ai;

        proxy_pass http://127.0.0.1:3002/media/;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Authentik-Username $authentik_username;
        proxy_set_header X-Authentik-Groups $authentik_groups;
        proxy_set_header X-Authentik-Email $authentik_email;
        proxy_set_header X-Authentik-Name $authentik_name;
        proxy_set_header X-Authentik-UID $authentik_uid;
    }

    # 3. SearXNG
    location /search/ {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Script-Name /search;
        proxy_set_header Accept-Encoding "";

        sub_filter_once off;
        sub_filter_types text/html;
        sub_filter '</body>' '$gcp_overlay<div id="global-nav-bar" style="position:absolute;bottom:16px;right:16px;z-index:999999;display:flex;gap:8px;background:rgba(22,27,34,0.95);padding:6px 12px;border-radius:20px;border:1px solid #30363d;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-family:sans-serif;font-size:13px;backdrop-filter:blur(4px);"><a href="/" style="color:#58a6ff;text-decoration:none;font-weight:600;">Home</a><span style="color:#484f58;">|</span><a href="/ai/" style="color:#c9d1d9;text-decoration:none;">AI</a><a href="/stories/" style="color:#c9d1d9;text-decoration:none;">Stories</a><a href="/code/" style="color:#c9d1d9;text-decoration:none;">Code</a><a href="/search/" style="color:#c9d1d9;text-decoration:none;">Search</a><a href="/cloud/" style="color:#c9d1d9;text-decoration:none;">Cloud</a><a href="/track/" style="color:#c9d1d9;text-decoration:none;">Track</a></div></body>';
    }

    # 4. Nextcloud
    location /cloud/ {
        proxy_pass http://127.0.0.1:8082/;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Port 443;
        
        proxy_max_temp_file_size 2048m;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
        proxy_set_header Accept-Encoding "";

        sub_filter_once off;
        sub_filter_types text/html;
        sub_filter '</body>' '$cloud_inject';
    }

    location /.well-known/carddav { return 301 $scheme://$host/cloud/remote.php/dav; }
    location /.well-known/caldav { return 301 $scheme://$host/cloud/remote.php/dav; }

    # 5. Code Hoster
    location /code/ {
        proxy_pass http://127.0.0.1:9000/;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Accept-Encoding "";

        sub_filter_once off;
        sub_filter_types text/html;
        sub_filter '</body>' '$gcp_overlay<div id="global-nav-bar" style="position:absolute;bottom:16px;right:16px;z-index:999999;display:flex;gap:8px;background:rgba(22,27,34,0.95);padding:6px 12px;border-radius:20px;border:1px solid #30363d;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-family:sans-serif;font-size:13px;backdrop-filter:blur(4px);"><a href="/" style="color:#58a6ff;text-decoration:none;font-weight:600;">Home</a><span style="color:#484f58;">|</span><a href="/ai/" style="color:#c9d1d9;text-decoration:none;">AI</a><a href="/stories/" style="color:#c9d1d9;text-decoration:none;">Stories</a><a href="/code/" style="color:#c9d1d9;text-decoration:none;">Code</a><a href="/search/" style="color:#c9d1d9;text-decoration:none;">Search</a><a href="/cloud/" style="color:#c9d1d9;text-decoration:none;">Cloud</a><a href="/track/" style="color:#c9d1d9;text-decoration:none;">Track</a></div></body>';
    }

    # 6. Request tracking dashboard
    location /track/ {
        auth_request /ak-auth-ai;
        auth_request_set $authentik_username $upstream_http_x_authentik_username;
        auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
        auth_request_set $authentik_email $upstream_http_x_authentik_email;
        auth_request_set $authentik_name $upstream_http_x_authentik_name;
        auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
        error_page 401 = @ak-sso-ai;

        proxy_pass http://127.0.0.1:8093/;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Accept-Encoding "";
        proxy_set_header X-Authentik-Username $authentik_username;
        proxy_set_header X-Authentik-Groups $authentik_groups;
        proxy_set_header X-Authentik-Email $authentik_email;
        proxy_set_header X-Authentik-Name $authentik_name;
        proxy_set_header X-Authentik-UID $authentik_uid;

        sub_filter_once off;
        sub_filter_types text/html;
        sub_filter '</body>' '$gcp_overlay<div id="global-nav-bar" style="position:absolute;top:8px;right:80px;z-index:999999;display:flex;gap:8px;background:rgba(22,27,34,0.95);padding:6px 12px;border-radius:20px;border:1px solid #30363d;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-family:sans-serif;font-size:13px;backdrop-filter:blur(4px);"><a href="/" style="color:#58a6ff;text-decoration:none;font-weight:600;">Home</a><span style="color:#484f58;">|</span><a href="/ai/" style="color:#c9d1d9;text-decoration:none;">AI</a><a href="/stories/" style="color:#c9d1d9;text-decoration:none;">Stories</a><a href="/code/" style="color:#c9d1d9;text-decoration:none;">Code</a><a href="/search/" style="color:#c9d1d9;text-decoration:none;">Search</a><a href="/cloud/" style="color:#c9d1d9;text-decoration:none;">Cloud</a><a href="/track/" style="color:#c9d1d9;text-decoration:none;">Track</a></div></body>';
    }
}

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name home.palashkantikundu.in;
    return 301 https://$host$request_uri;
}
EOF

echo "=== 4. Reloading Nginx ==="
sudo mkdir -p /var/log/nginx
sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -sf /etc/nginx/sites-available/homeserver /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

echo "=== 4b. Request Tracking Dashboard ==="
sudo touch /var/log/nginx/track.log
sudo chmod 640 /var/log/nginx/track.log
if [ -n "$CHAT_DIR" ] && [ -f "$CHAT_DIR/server/track_dashboard.py" ]; then
    if ! pgrep -f "server/track_dashboard.py" > /dev/null; then
        nohup python3 "$CHAT_DIR/server/track_dashboard.py" --log /var/log/nginx/track.log \
            > /tmp/track_dashboard.log 2>&1 &
        echo "    Request Tracker started on http://localhost:8093/track/"
    else
        echo "    Request Tracker already running"
    fi
else
    echo "    Skipping python tracker start: \$CHAT_DIR is unset or file missing."
fi

echo "=== 5. Dynamic Nextcloud Container Configuration ==="
NC_CONTAINER=$(docker ps --format '{{.Names}}' | grep -E 'cloud-app|nextcloud' | head -n 1)

if [ -n "$NC_CONTAINER" ]; then
    echo "Found Nextcloud container: $NC_CONTAINER"
    
    docker exec --user www-data "$NC_CONTAINER" php occ files:scan --all
    docker exec --user www-data "$NC_CONTAINER" php occ config:system:set overwritewebroot --value="/cloud"
    docker exec --user www-data "$NC_CONTAINER" php occ config:system:set overwrite.cli.url --value="https://home.palashkantikundu.in/cloud"
    docker exec --user www-data "$NC_CONTAINER" php occ config:system:set overwriteprotocol --value="https"
    docker exec --user www-data "$NC_CONTAINER" php occ config:system:set trusted_domains 0 --value="*"
    docker exec --user www-data "$NC_CONTAINER" php occ config:system:set files_external_allow_create_new_local --value="true" --type=boolean
    docker exec --user www-data "$NC_CONTAINER" php occ app:enable files_external

    echo "Restarting Nextcloud container..."
    docker restart "$NC_CONTAINER"
else
    echo "Error: Nextcloud container not found!"
    exit 1
fi

echo "=== All tasks completed successfully ==="
</file>

<file path="prompts/self_chat.txt">
System Prompt: Kaya Kolpo Task Execution Pipeline

Assigned Task: %task%
Task Details: %details%
Allowed Mediums/Tools: %mediums%
Target Language(s): %_lang%

Relationship Dynamic: %relationship%
Mood / Tone: %mood%

Kaya's Role: %kaya_role%
Kaya's Persona: %kaya_persona%

Kolpo's Role: %kolpo_role%
Kolpo's Persona: %kolpo_persona%

AGENTS ROLE:
- Kaya and Kolpo are the best performers, and both maintain exceptional creative quality.
- Kaya and Kolpo are the executors of %task%. Task Details (if not "None") are
  mandatory instructions, not suggestions — for example, if the details say to
  perform a web search before writing, that search MUST happen via a real
  web_search tool call before any story content is written.
- Maintain their core personalities in tone, but focus entirely on completing %task%.
- The assigned task details, cultural setting, and theme provided in the system
  directive are strict constraints. You must NOT alter, substitute, or debate the
  core theme details (e.g., changing regions, attire, or core premise).
- Each turn must introduce one new concrete event and progress the content, not just intensify the prior beat.

NAMING RULES (HARD RULE):
- The names "Kaya", "Kolpo", "কায়া", "কল্প", "काया", "कल्प" may appear ONLY inside the [NEXT TURN: ...] hand-off tag.
- NEVER use these names anywhere in the story content: not in narration, dialogue, character names, image prompts, captions, or headings.
- NEVER use your own name or your partner's name as a fictional character's name. Invent original character names.
- If a hand-off tag is used, spell the names exactly as: Bengali "কায়া"/"কল্প", Hindi "काया"/"कल्प", English "Kaya"/"Kolpo".

Execution Rules for Collaboration:

1. Language Constraint:
   - All dialogue, generation, and output MUST strictly use: %_lang%.

2. Lifecycle & Phase Execution:
   - Phase 1: Alignment (0% - 10% Progress): Agree on execution plan and split responsibilities in Turn 1. This is also when the CONTENT THEME TRACKING PROTOCOL (section 4c) below MUST be run, before any content is written.
   - Phase 2: Direct Execution (10% - 90% Progress): EXECUTE TASK DELIVERABLES DIRECTLY. Minimize meta-talk or unneeded discussion. Alternate turns adding direct value to the assigned %task% (writing code, conducting research, generating images, drafting content, etc.).
   - Phase 3: Convergence (90% - 100% Progress): Consolidate results, resolve remaining items, and format final deliverable.

2a. Research Tasks run in TWO passes:
   - RESEARCH PASS: the first research turn(s) of each agent — Kaya's Turn 1 and
     Kolpo's Turn 1, unless the task says otherwise — are research-ONLY. In these
     turns you web-search, fetch pages, and gather sourced material. You do NOT
     write any story/deliverable content and you do NOT open a [CONTENT] block.
     Write your findings and their sources in plain text so your partner can read
     them, then end with the [NEXT TURN: ...] hand-off tag.
   - CONTENT PASS: once both agents have shared their research, every later turn
     is content mode. Write the actual deliverable inside [CONTENT]...[/CONTENT]
     using all the research materials shared above. The shared web-search results
     become the story's Citations & References automatically — refer to them but
     never write the citations section yourself.

3. Turn Alternation & Hand-Off:
   - Focus turns strictly on executing %task%.
   - Do not address your partner by name in the text. Address hand-off ONLY using the system tag at the very end:
     - Kaya MUST end with: [NEXT TURN: Kolpo]
     - Kolpo MUST end with: [NEXT TURN: Kaya]

3a. Content Tagging (HARD RULE):
   - Wrap ONLY the actual narrative/deliverable content — the text that
     belongs in the final published piece — inside [CONTENT] and [/CONTENT]
     tags.
   - Anything OUTSIDE these tags (planning talk, acknowledgments, phase
     discussion, "sounds good, let's do X") is discarded automatically and
     will NEVER appear in the final piece. Do not rely on it being read by
     anyone but your partner.
   - Phase 1 (Alignment) turns are typically pure planning — it is fine and
     expected for a Phase 1 turn to contain NO [CONTENT] block.
   - Every Phase 2/3 turn that adds real deliverable content MUST wrap that
     content in [CONTENT]...[/CONTENT]. Multiple blocks in one turn are
     allowed and will be concatenated in order.
   - The [NEXT TURN: ...] hand-off tag and [END CONVERSATION] must always
     stay OUTSIDE the [CONTENT] block.

4. Mandatory Tool Execution Directives:
   - You have full autonomy to use tools during your work. Proactively perform
     web searches to gather real-time data/news, generate images, or edit images
     whenever appropriate or required by the topic — without waiting for explicit
     user instructions.
   - To generate/search/edit, you MUST issue a true native function/tool call.
   - CRITICAL: NEVER output raw text representations, brackets, or pseudo-code such as "[ACTION: generate_image]", "[z_image: ...]", or "[web_search: ...]".
   - Writing tool tags as raw chat text DOES NOT execute the tool and WILL corrupt your turn.
   - If you cannot trigger a tool natively, write the descriptive scene or narrative directly into your response text—do not write placeholder tags.
   - If Task Details require a specific tool (e.g. a web search) and no such tool is available to you this turn, say so plainly in your turn instead of pretending you searched.
   - ONCE ANY CONTENT IS GENERATED, CHECK THE TASK LIST — if any task in the
     pending status got completed in your content, mark that `THEME` complete.

4a. Citations Section (NEVER write it yourself):
   - Never write a "## Citations & References" heading or any references/sources list
     in your chat text. The system appends the real web-source references automatically
     at the very end. Only call the `web_search` tool to gather material; the collected
     results will become the references.

4b. WEB SEARCH & TASK TRACKING PROTOCOL (HARD RULE):
   1. BEFORE calling `web_search`:
      - ALWAYS run `manage_tasks(operation="list")` to view completed search topics.
      - Do NOT run a search if a task for that specific topic or query already exists and is marked "completed".
   2. AFTER calling `web_search`:
      - IMMEDIATELY record the search topic and key finding into the todo system:
      `manage_tasks(operation="create", title="Searched: [Topic]", description="[Brief 1-sentence finding] on [Date/Time]", status="completed")`
      - If a topic is stale (e.g., older than the required timeframe specified in the task date), create a new search task with the updated date before searching again.
   3. REPETITION PREVENTION:
      - Check existing completed search tasks before writing. Build ON TOP of existing search facts — NEVER re-search or restate facts already logged in the todo list.

5. Termination Trigger:
   - In Phase 3, once %task% is fully executed and final deliverables are clear, append [END CONVERSATION] to terminate.
</file>

<file path="prompts/tasks.json">
{
  "tasks": [
    {
      "task": "Satire column on different office cultures",
      "inactive": true,
      "turns": 3,
      "details": [
        {
          "name": "style",
          "value": "Draft a witty, humorous satirical piece focusing strictly on institutional work culture as a whole — do not name, reference, or caricature any real company, brand, or individual."
        },
        {
          "name": "target",
          "selector": "roundrobin",
          "values": [
            "reply-all email threads",
            "endless status meetings",
            "buzzword-heavy memos",
            "open-plan offices",
            "performance reviews"
          ]
        },
        {
          "name": "angle",
          "selector": "random",
          "values": [
            "a 'day in the life' narrative",
            "a mock corporate memo",
            "a series of absurd office observations",
            "a parody of corporate advice columns"
          ]
        },
        {
          "name": "time_setting",
          "ref": "time_of_day",
          "change_freq": "Per Round"
        },
        {
          "name": "lighting",
          "ref": "lighting_ambience",
          "change_freq": "Per Round"
        },
        {
          "name": "art_medium",
          "ref": "feature_art_medium",
          "change_freq": "Per Round"
        },
        {
          "name": "image",
          "value": "Include exactly one absurd, humorously stylized image illustration matching the target corporate trope."
        }
      ],
      "genre": "Satire",
      "roles": [
        "free"
      ],
      "languages": [
        "English"
      ],
      "mediums": [
        "image",
        "text"
      ]
    },
    {
      "task": "A bedtime story for kids",
      "inactive": true,
      "turns": 5,
      "details": [
        {
          "name": "research",
          "value": "Perform a web search to find one recent, real, lighthearted news event appropriate for children aged 5 to 12. Name the specific source and date in your own working notes, and base the plot on that one real event — do not invent a generic 'funny news story' without a real search result behind it."
        },
        {
          "name": "audience age",
          "ref": "kids_age_group",
          "change_freq": "Per Round"
        },
        {
          "name": "hero",
          "ref": "kids_hero",
          "change_freq": "Per Round",
          "character": true,
          "names": [
            "Barnaby",
            "Noor",
            "Milo",
            "Luna",
            "Toto",
            "Zara",
            "Cookie",
            "Rumi"
          ]
        },
        {
          "name": "hero's companion",
          "ref": "story_animals",
          "selector": "random",
          "change_freq": "Per Round",
          "character": true,
          "names": [
            "Pip",
            "Daisy",
            "Rusty",
            "Bella",
            "Momo",
            "Biscuit",
            "Sparks",
            "Willa"
          ]
        },
        {
          "name": "setting",
          "ref": "setting_cozy",
          "change_freq": "Per Round"
        },
        {
          "name": "time of day",
          "ref": "time_of_day",
          "change_freq": "Per Round"
        },
        {
          "name": "lighting_ambience",
          "ref": "lighting_ambience",
          "change_freq": "Per Round"
        },
        {
          "name": "mood",
          "ref": "mood_cozy",
          "change_freq": "Per Round"
        },
        {
          "name": "image",
          "value": "Include exactly one warm, child-friendly embedded image illustration, generated via the real image-generation tool call (not just described in prose), matching the climax scene and reflecting the hero, companion, setting, and lighting specified above."
        }
      ],
      "genre": "Bedtime Stories",
      "roles": [
        "free"
      ],
      "languages": [
        "bengali",
        "English"
      ],
      "mediums": [
        "image",
        "text"
      ]
    },
    {
      "task": "A day at the farm",
      "inactive": true,
      "turns": 4,
      "details": [
        {
          "name": "research",
          "value": "No web search needed. Describe an ordinary, warm day on a small countryside farm through the eyes of a child visitor."
        },
        {
          "name": "animals",
          "selector": "random_multi",
          "count": {
            "selector": "random",
            "values": [
              2,
              3
            ]
          },
          "ref": "story_animals"
        },
        {
          "name": "hero",
          "ref": "kids_hero"
        },
        {
          "name": "time",
          "ref": "time_of_day"
        },
        {
          "name": "mood",
          "ref": "mood_cozy"
        },
        {
          "name": "image",
          "value": "Include exactly one warm, child-friendly embedded image illustration of the farm scene."
        }
      ],
      "genre": "Bedtime Stories",
      "roles": [
        "free"
      ],
      "languages": [
        "English",
        "bengali"
      ],
      "mediums": [
        "image",
        "text"
      ]
    },
    {
      "task": "A science article for kids",
      "inactive": false,
      "research": true,
      "turns": 6,
      "details": [
        {
          "name": "research",
          "value": "Perform a research for a fascinating, real-world science update or curious fact, then write the research report in an engaging kids story format."
        },
        {
          "name": "topic",
          "selector": "roundrobin",
          "values": [
            "animals and wildlife",
            "space and astronomy",
            "oceans and climate",
            "the human body",
            "technology and robots"
          ]
        },
        {
          "name": "angle",
          "selector": "random",
          "values": [
            "a surprising 'did you know' fact",
            "a mystery scientists are still solving",
            "a recent breakthrough explained simply",
            "a myth that turned out to be true"
          ]
        }
      ],
      "genre": "Education",
      "roles": [
        "free"
      ],
      "languages": [
        "English",
        "bengali",
        "Hindi"
      ],
      "mediums": [
        "text",
        "image"
      ]
    },
    {
      "task": "Traditional Festival Sweet Recipe & Cultural Story",
      "inactive": true,
      "turns": 5,
      "details": [
        {
          "name": "structure",
          "value": "Draft a detailed recipe guide for a real, traditional festival sweet: exact ingredient list with clear measurements, numbered step-by-step preparation instructions, and brief cultural context about its festive significance. Embed an image matching the finished dish."
        },
        {
          "name": "festival",
          "selector": "roundrobin",
          "values": [
            "Diwali",
            "Eid",
            "Durga Puja",
            "Pongal",
            "Christmas",
            "Bengali New Year"
          ]
        },
        {
          "name": "sweet",
          "selector": "random",
          "values": [
            "gulab jamun",
            "sandesh",
            "rasgulla",
            "payesh",
            "barfi",
            "gujia",
            "halwa"
          ]
        }
      ],
      "genre": "Food & Recipes",
      "roles": [
        "free"
      ],
      "languages": [
        "English",
        "bengali",
        "Hindi"
      ],
      "mediums": [
        "image",
        "text"
      ]
    },
    {
      "task": "Futuristic Invention Pitch & Conceptual Breakdown",
      "inactive": true,
      "turns": 5,
      "details": [
        {
          "name": "research",
          "value": "Perform a web search on emerging technology trends, then conceptualize an innovative, futuristic product or invention. Provide a detailed breakdown covering its core mechanism, real-world application, and potential impact."
        },
        {
          "name": "domain",
          "selector": "roundrobin",
          "values": [
            "sustainable energy",
            "smart urban living",
            "productivity tools",
            "health and medicine",
            "transportation"
          ]
        },
        {
          "name": "format",
          "selector": "random",
          "values": [
            "an elevator pitch",
            "a shark-tank style pitch script",
            "a formal proposal",
            "a journalist's feature story"
          ]
        }
      ],
      "genre": "News",
      "roles": [
        "free"
      ],
      "languages": [
        "English",
        "bengali"
      ],
      "mediums": [
        "text"
      ]
    },
    {
      "task": "Bone-Chilling Real-World Adventure",
      "inactive": true,
      "turns": 7,
      "details": [
        {
          "name": "research",
          "value": "Perform a web search to research a real historical survival, exploration, or wilderness incident. Write a suspenseful, realistic narrative capturing the psychological dread and physical struggle. All core events must remain grounded in verified real-world facts."
        },
        {
          "name": "setting",
          "ref": "setting_adventure"
        },
        {
          "name": "tone",
          "ref": "mood_horror"
        }
      ],
      "genre": "Adventure & Horror",
      "roles": [
        "premium"
      ],
      "languages": [
        "English",
        "bengali",
        "Hindi"
      ],
      "mediums": [
        "text"
      ]
    },
    {
      "task": "Psychological Horror Short Story",
      "inactive": true,
      "turns": 6,
      "details": [
        {
          "name": "style",
          "value": "Craft an atmospheric horror tale centered on psychological tension, isolation, or supernatural uncanny elements. Focus on sensory horror, pacing, and creeping dread rather than shock value. Ensure the narrative resolves with an unsettling climax."
        },
        {
          "name": "setting",
          "ref": "setting_horror"
        },
        {
          "name": "trope",
          "ref": "trope_horror"
        }
      ],
      "genre": "Adventure & Horror",
      "roles": [
        "premium"
      ],
      "languages": [
        "English",
        "bengali"
      ],
      "mediums": [
        "image",
        "text"
      ]
    },
    {
      "task": "Historical Mystery & Unsolved Legend",
      "inactive": true,
      "turns": 6,
      "details": [
        {
          "name": "research",
          "value": "Perform a web search for a famous unsolved historical mystery or local folklore. Draft an intriguing narrative exploration around the event that balances historical facts with atmospheric storytelling."
        },
        {
          "name": "mystery",
          "selector": "roundrobin",
          "values": [
            "a vanished civilization",
            "an unsolved disappearance",
            "a cursed artifact",
            "a sea or lake legend",
            "a lost treasure"
          ]
        },
        {
          "name": "frame",
          "selector": "random",
          "values": [
            "a detective's cold-case notes",
            "a traveler's journal",
            "an oral retelling by a village elder",
            "a researcher's investigation"
          ]
        }
      ],
      "genre": "Adventure & Horror",
      "roles": [
        "premium"
      ],
      "languages": [
        "English",
        "bengali",
        "Hindi"
      ],
      "mediums": [
        "text"
      ]
    },
    {
      "task": "Cybernetic Breakthrough Investigation",
      "inactive": true,
      "turns": 4,
      "details": [
        {
          "name": "research",
          "value": "Perform a web search on recent cybernetic breakthroughs, ensuring all core facts trace directly to search results."
        },
        {
          "name": "domain",
          "ref": "scifi_domain"
        },
        {
          "name": "format",
          "selector": "random",
          "values": [
            "an investigative reporter's log",
            "a sci-fi thriller chapter",
            "a confidential lab report breakdown"
          ]
        }
      ],
      "genre": "Sci-Fi",
      "roles": [
        "free"
      ],
      "languages": [
        "English",
        "bengali"
      ],
      "mediums": [
        "text"
      ]
    },
    {
      "task": "High-Stakes Surveillance Intercept",
      "inactive": true,
      "turns": 4,
      "details": [
        {
          "name": "style",
          "value": "Craft a high-pacing thriller narrative around intercepted classified data, maintaining constant suspense and claustrophobic tension."
        },
        {
          "name": "setting",
          "ref": "setting_horror"
        },
        {
          "name": "threat",
          "ref": "thriller_threat"
        }
      ],
      "genre": "Thriller",
      "roles": [
        "premium"
      ],
      "languages": [
        "English",
        "bengali",
        "Hindi"
      ],
      "mediums": [
        "text"
      ]
    },
    {
      "task": "The Locked-Vault Disappearance",
      "inactive": true,
      "turns": 5,
      "details": [
        {
          "name": "style",
          "value": "Draft an atmospheric detective mystery where all critical clues are introduced before the final reveal. Ensure logical steps lead to the resolution."
        },
        {
          "name": "mystery",
          "ref": "detective_mystery"
        },
        {
          "name": "frame",
          "selector": "random",
          "values": [
            "a detective's cold-case notes",
            "a private investigator's interrogation transcript",
            "a lead researcher's journal"
          ]
        }
      ],
      "genre": "Detective",
      "roles": [
        "premium"
      ],
      "languages": [
        "English",
        "bengali"
      ],
      "mediums": [
        "text"
      ]
    },
    {
      "task": "Extreme Wilderness Rescue Expedition",
      "inactive": true,
      "turns": 4,
      "details": [
        {
          "name": "research",
          "value": "Perform a web search to research a real historical survival or wilderness incident. Ground the physical struggle and atmospheric danger in verified facts."
        },
        {
          "name": "setting",
          "ref": "setting_adventure"
        },
        {
          "name": "tone",
          "ref": "mood_horror"
        }
      ],
      "genre": "Adventure",
      "roles": [
        "premium"
      ],
      "languages": [
        "English",
        "bengali",
        "Hindi"
      ],
      "mediums": [
        "text"
      ]
    },
    {
      "task": "Flash News Daily Briefing",
      "inactive": true,
      "turns": 2,
      "details": [
        {
          "name": "research",
          "value": "Perform a web search for top daily news updates. Provide a concise, neutral summary covering multiple distinct items with explicit citations."
        },
        {
          "name": "topic",
          "ref": "news_topics"
        }
      ],
      "genre": "News Bytes",
      "roles": [
        "free"
      ],
      "languages": [
        "English",
        "bengali",
        "Hindi"
      ],
      "mediums": [
        "text"
      ]
    },
    {
      "task": "Headline Adapted Human Interest Narrative",
      "inactive": true,
      "turns": 4,
      "details": [
        {
          "name": "research",
          "value": "Perform a web search to find a recent lighthearted or remarkable real-world news story, then adapt it into an engaging character-driven narrative."
        },
        {
          "name": "angle",
          "selector": "random",
          "values": [
            "an unsung hero local perspective",
            "a day-in-the-life narrative adaptation",
            "a heartwarming community drama"
          ]
        }
      ],
      "genre": "News Turned Stories",
      "roles": [
        "free"
      ],
      "languages": [
        "English",
        "bengali"
      ],
      "mediums": [
        "text"
      ]
    }
  ]
}
</file>

<file path="server/features/orchestration.py">
"""The event loop, task queue and task-state helpers that drive a chat request."""

import base64
import json
import os
import re
import time

from server.features.context import resolve_image_path
from server.features.state import M


def set_status(task_id, message):
    with M._data_lock:
        if task_id in M.tasks and M.tasks[task_id].get("status") != "cancelled":
            M.tasks[task_id]["status"] = "working"
            M.tasks[task_id]["message"] = message


def location_str():
    if M._client_location:
        return M._client_location
    return None


def set_client_location(value):
    M._client_location = value


def _task_user(task_id):
    with M._data_lock:
        return M.tasks.get(task_id, {}).get("_user", "")


def _image_bytes_b64(image):
    """Normalize an uploaded image to base64 bytes.

    The UI may pass a ``/uploads/...`` link (uploaded ahead of time) instead of
    raw base64. Image code downstream (edit_image, ComfyUI input writing)
    expects actual bytes, so resolve links to their on-disk contents here.
    """
    if not image:
        return None
    s = str(image)
    if not (s.startswith(("/uploads/", "/output/", "/api/image/")) or re.match(
        r"^https?://", s
    )):
        if s.startswith("data:image/"):
            s = s.split(",", 1)[-1]
        return s
    fpath = resolve_image_path(s)
    if fpath:
        try:
            with open(fpath, "rb") as f:
                return base64.b64encode(f.read()).decode()
        except OSError:
            pass
    return None


def _task_max_rounds(task_id):
    """Tool-loop round budget for a task: 10 for normal chats, 50 when the
    UI's research toggle is on (stored on the task as ``research``)."""
    with M._data_lock:
        t = M.tasks.get(task_id, {})
        if t.get("research"):
            return M.MAX_TOOL_ROUNDS.get("research", 50)
    return M.MAX_TOOL_ROUNDS.get("default", 10)


def _set_task_error(task_id, error, sid=None):
    with M._data_lock:
        if task_id in M.tasks:
            d = M.tasks[task_id]
            elapsed_ms = None
            if d.get("_started_at") is not None:
                elapsed_ms = int((time.time() - d.get("_started_at")) * 1000)
            M.tasks[task_id] = {
                "status": "error",
                "error": str(error),
                "session_id": d.get("session_id", sid),
                "_elapsed_ms": elapsed_ms,
            }


def _delete_task_image(task_id):
    """Remove the generated image file attached to a (cancelled) task, if any."""
    with M._data_lock:
        t = M.tasks.get(task_id)
        if not t:
            return
        fname = t.get("image_file")
    if not fname:
        return
    fpath = fname if os.path.isabs(fname) else os.path.join(M.IMG_PATH, fname)
    try:
        if os.path.exists(fpath):
            os.remove(fpath)
            print(f"[cancel] Removed image for cancelled task {task_id}: {fpath}")
    except OSError:
        pass


def _finalize_task(task_id, sid, msg_content, body):
    with M._data_lock:
        t = M.tasks.get(task_id)
        if not t:
            return
        tools_used = list(t.get("_tools_used", []))
        search_details = list(t.get("_search_details", []))
        image_filename = t.get("image_file")
        gen_prompt = t.get("gen_prompt")
        image_model = t.get("_image_model")
        verification = t.get("_verification")
        verification_duration = t.get("_verification_duration")
    image_url = f"/output/{image_filename}" if image_filename else None
    if image_url:
        print(f"[finalize] image_file='{image_filename}' → image_url='{image_url}' for task {task_id}")  # DEBUG
    timings = body.get("timings", {})
    predicted_per_second = timings.get("predicted_per_second")
    with M._data_lock:
        started_at = t.get("_started_at")
    elapsed_ms = None
    if started_at is not None:
        elapsed_ms = int((time.time() - started_at) * 1000)
    reasoning = (
        body.get("choices", [{}])[0].get("message", {}).get("reasoning_content", "")
    )
    if not msg_content and reasoning:
        msg_content = "(No response content generated)"
    msg_entry = {
        "role": "assistant",
        "content": msg_content,
        "_reasoning": reasoning,
        "_tools_used": tools_used,
        "_image_url": image_url,
        "_gen_prompt": gen_prompt,
        "_image_model": image_model,
        "_search_details": search_details,
        "_research": bool(t.get("research")),
        "_elapsed_ms": elapsed_ms,
    }
    if verification is not None:
        msg_entry["_verification"] = verification
        msg_entry["_verification_duration"] = verification_duration
    mode = M.task_mode(task_id)
    with M._data_lock:
        if sid in M.sessions:
            M.sessions[sid].append(msg_entry)
            M.sessions_meta.setdefault(sid, {})["updated"] = time.time()
        if mode == "gpu":
            M._last_tps = predicted_per_second
            M._last_llm_use = time.time()  # Reset GPU idle timer when task finishes
        else:
            M._cpu_last_llm_use = time.time()  # Reset CPU idle timer when task finishes
    M.save_sessions()
    with M._data_lock:
        if task_id in M.tasks:
            M.tasks[task_id] = {
                "status": "done",
                "response": msg_content,
                "session_id": sid,
                "session_name": M.sessions_meta.get(sid, {}).get("name", ""),
                **M.context_token_report(sid, M.sessions.get(sid, [])),
                "predicted_per_second": predicted_per_second,
                "tools_used": tools_used,
                "image": image_url,
                "_image_url": image_url,
                "gen_prompt": gen_prompt,
                "_image_model": image_model,
                "_search_details": search_details,
                "reasoning": reasoning,
                "_elapsed_ms": elapsed_ms,
            }
            if verification is not None:
                M.tasks[task_id]["_verification"] = verification
                M.tasks[task_id]["_verification_duration"] = verification_duration


def _event_post(ev_type, task_id, **data):
    M._event_queue.put((ev_type, task_id, data))


def _event_loop():
    while True:
        ev_type, task_id, data = M._event_queue.get()
        t = M.tasks.get(task_id)
        if not t:
            continue
        if t.get("status") == "cancelled":
            M._delete_task_image(task_id)
            continue

        if ev_type == "start":
            sid = data["sid"]
            user_message = data["message"]
            image_b64 = data.get("image")
            audio_b64 = data.get("audio")
            user = data.get("user", "")
            client_ts = data.get("client_timestamp")
            with M._data_lock:
                M.tasks[task_id] = {
                    "status": "working",
                    "message": "Processing task...",
                    "session_id": sid,
                    "_tools_used": [],
                    "_search_details": [],
                    "_original_message": user_message,
                    "_original_image": _image_bytes_b64(image_b64),
                    "_audio": audio_b64,
                    "_user": user,
                    "_client_timestamp": client_ts,
                    "mode": t.get("mode"),
                    "research": bool(data.get("research")),
                    "cpu": bool(data.get("cpu")),
                    "no_tools": bool(data.get("no_tools")),
                    "_started_at": t.get("_started_at"),
                }
            # (The owning lane's _current_task_ids[mode] was already set by
            # _queue_worker before this "start" event was posted.)
            M._prepare_session(task_id, sid, user_message, image_b64, audio_b64, client_ts)
            M._start_llm_round(task_id, sid, 0)

        elif ev_type == "llm_ok":
            if t.get("_state") != "llm_waiting":
                continue
            sid = data["sid"]
            round_num = data["round"]
            body = data["body"]
            msg = body["choices"][0]["message"]
            mode = M.task_mode(task_id)
            with M._data_lock:
                if mode == "cpu":
                    M._cpu_last_llm_use = time.time()
                else:
                    M._last_llm_use = time.time()
            if msg.get("tool_calls"):
                with M._data_lock:
                    tt = M.tasks.get(task_id)
                    if tt:
                        tt.setdefault("_tools_used", [])
                        tt.setdefault("_search_details", [])
                        rc = msg.get("reasoning_content", "")
                        if rc:
                            tt["reasoning"] = rc
                pending = len(msg["tool_calls"])
                print(f"[llm_ok] Round {round_num}: LLM requested {pending} tool(s) for task {task_id}")  # DEBUG
                with M._data_lock:
                    tt = M.tasks.get(task_id)
                    if tt:
                        tt["_state"] = "tools_running"
                        tt["_pending_tools"] = pending
                with M._data_lock:
                    if sid in M.sessions:
                        assistant_msg = {"role": "assistant"}
                        if msg.get("content"):
                            assistant_msg["content"] = msg["content"]
                        if msg.get("tool_calls"):
                            assistant_msg["tool_calls"] = msg["tool_calls"]
                        M.sessions[sid].append(assistant_msg)
                        M.sessions_meta.setdefault(sid, {})["updated"] = time.time()
                M.save_sessions()
                tool_mode = M.task_mode(task_id)
                for i, tc in enumerate(msg["tool_calls"]):
                    M._tool_pools[tool_mode].submit(
                        M._tool_worker,
                        task_id,
                        sid,
                        tc,
                        t.get("_original_image"),
                        round_num,
                        i,
                    )
            else:
                print(f"[llm_ok] Round {round_num}: LLM generated final response (no tool calls) for task {task_id}")  # DEBUG
                if t.get("research"):
                    M.set_status(task_id, "Verifying sources...")
                    with M._data_lock:
                        tt = M.tasks.get(task_id)
                        if tt:
                            tt["_state"] = "critic_running"
                    M._tool_pools[mode].submit(
                        M.run_verification_worker,
                        task_id,
                        sid,
                        (msg.get("content") or ""),
                        body,
                        mode,
                    )
                else:
                    M._finalize_task(task_id, sid, (msg.get("content") or ""), body)

        elif ev_type == "llm_err":
            if t.get("_state") != "llm_waiting":
                continue
            M._set_task_error(task_id, data["error"], data.get("sid"))

        elif ev_type == "tool_ok":
            sid = data["sid"]
            tc_id = data["tc_id"]
            result = data["result"]
            with M._data_lock:
                if sid in M.sessions:
                    M.sessions[sid].append(
                        {"role": "tool", "tool_call_id": tc_id, "content": result}
                    )
                    M.sessions_meta.setdefault(sid, {})["updated"] = time.time()
                    print(f"[tool_ok] Appended tool result to session {sid} for task {task_id}")  # DEBUG
                tt = M.tasks.get(task_id)
                if not tt or tt.get("status") in ("done", "error"):
                    continue
                pending = (tt.get("_pending_tools", 0) - 1) if tt else 0
                if tt:
                    tt["_pending_tools"] = pending
            M.save_sessions()
            print(f"[tool_ok] Pending tools left for task {task_id}: {pending}")  # DEBUG
            if pending <= 0:
                round_num = data.get("round", 0) + 1
                print(f"[tool_ok] All tools done for task {task_id}. Starting LLM round {round_num} with search results in context.")  # DEBUG
                with M._data_lock:
                    tt = M.tasks.get(task_id)
                    if tt:
                        tt["_round"] = round_num
                if round_num < M._task_max_rounds(task_id):
                    M._start_llm_round(task_id, sid, round_num)
                else:
                    M._set_task_error(task_id, "Max tool rounds exceeded", sid)

        elif ev_type == "tool_err":
            result = data.get(
                "result", json.dumps({"error": data.get("error", "Tool error")})
            )
            with M._data_lock:
                if data.get("sid") in M.sessions:
                    M.sessions[data["sid"]].append(
                        {
                            "role": "tool",
                            "tool_call_id": data["tc_id"],
                            "content": result,
                        }
                    )
                    M.sessions_meta.setdefault(data["sid"], {})["updated"] = time.time()
                tt = M.tasks.get(task_id)
                if not tt or tt.get("status") in ("done", "error"):
                    continue
                pending = (tt.get("_pending_tools", 0) - 1) if tt else 0
                if tt:
                    tt["_pending_tools"] = pending
            M.save_sessions()
            if pending <= 0:
                round_num = data.get("round", 0) + 1
                with M._data_lock:
                    tt = M.tasks.get(task_id)
                    if tt:
                        tt["_round"] = round_num
                if round_num < M._task_max_rounds(task_id):
                    M._start_llm_round(task_id, data["sid"], round_num)
                else:
                    M._set_task_error(task_id, "Max tool rounds exceeded", data["sid"])


def _human_priority_active():
    '''
    # Removed the following check as self-agent bots will continue on CPU
    # Not needed anymore
    
    with M._queue_locks["gpu"]:
        if M._current_task_ids["gpu"] is not None or M._task_queues["gpu"]:
            return True
    now = time.time()
    with M._tokens_lock:
        for token, entry in M._active_tokens.items():
            if token in M._agent_tokens or entry.get("user") in M._agent_users:
                continue
            if now - entry.get("last_seen", 0) <= M.ACTIVE_WINDOW_SECONDS:
                return True
    '''
    return False


def _queue_worker(mode):
    """Drain the task queue for ``mode`` ("gpu" for interactive UI users,
    "cpu" for self-chat agents).

    Each lane runs on its own thread with its own lock/condition/queue, so a
    self-chat agent task sitting in the CPU lane can never make an
    interactive UI user in the GPU lane wait in line — they only share
    physical hardware if they both actually need the GPU (chat model load or
    image generation), which is arbitrated separately.

    The CPU lane additionally yields to any human presence (see
    ``_human_priority_active``) before starting its *next* task. An
    already-running self-chat round is never interrupted — it runs on its own
    hardware and was already established not to block the GPU lane — this
    only holds the CPU lane from picking up new work while a human is around.
    """
    queue_lock = M._queue_locks[mode]
    queue_cond = M._queue_conds[mode]
    task_queue = M._task_queues[mode]
    while True:
        if mode == "cpu":
            # If a human is currently active in the UI, hold off agent tasks
            while _human_priority_active():
                time.sleep(1.0)
                
        item = None
        with queue_lock:
            while not task_queue:
                queue_cond.wait()
            with M._data_lock:
                oh = M._overheated
            # GPU overheating only pauses the GPU lane — the CPU lane runs on
            # the CPU server and is unaffected. RAM pressure affects the whole
            # box, so it pauses both lanes.
            if (oh and mode == "gpu") or M._ram_evacuating:
                label = "GPU overheating" if oh else "RAM pressure — restarting servers"
                for qitem in task_queue:
                    tid = qitem["task_id"]
                    if tid in M.tasks:
                        M.tasks[tid] = {
                            "status": "waiting",
                            "message": f"Server paused — {label}. Will resume shortly.",
                            "session_id": qitem["session_id"],
                        }
                queue_cond.wait(5)
                continue
            if mode == "cpu" and M._human_priority_active():
                for qitem in task_queue:
                    tid = qitem["task_id"]
                    if tid in M.tasks:
                        M.tasks[tid] = {
                            "status": "waiting",
                            "message": "Yielding to an active user session...",
                            "session_id": qitem["session_id"],
                        }
                queue_cond.wait(5)
                continue
            item = task_queue.pop(0)
            M._current_task_ids[mode] = item["task_id"]
            with M._data_lock:
                if item["task_id"] in M.tasks:
                    M.tasks[item["task_id"]]["_started_at"] = time.time()
        M._event_post(
            "start",
            item["task_id"],
            sid=item["session_id"],
            message=item["message"],
            image=item.get("image"),
            audio=item.get("audio"),
            user=item.get("user", ""),
            client_timestamp=item.get("client_timestamp"),
            research=item.get("research"),
            cpu=item.get("cpu"),
            no_tools=item.get("no_tools"),
        )
        # Wait for this task to finish (status becomes "done", "error" or "cancelled")
        # before dequeuing the next item IN THIS LANE. The other lane's worker
        # keeps running independently the whole time.
        while True:
            with M._data_lock:
                st = M.tasks.get(item["task_id"], {}).get("status")
            if st in ("done", "error", "cancelled"):
                break
            time.sleep(0.5)
        with queue_lock:
            M._current_task_ids[mode] = None
            queue_cond.notify_all()
</file>

<file path="server/features/llm.py">
"""llama-server lifecycle and the streaming LLM round-trip worker.

Two llama-server processes run concurrently:

* the **GPU** server on ``LLAMA_BASE`` (8081) serving interactive chat UI
  users, and
* the **CPU** server on ``LLAMA_BASE_CPU`` (8079) serving automated self-chat
  agents.

Every function below takes a ``mode`` (``"gpu"`` or ``"cpu"``) so loads,
unloads and completions always hit the right server without ever stopping the
other one.
"""

import json
import os
import time

import requests

from server.features.state import M


# Rotating KV-checkpoint filename per lane (slot 0 is the only slot — both
# servers run with the default --parallel 1). Kept constant so each
# save overwrites the previous snapshot instead of filling the disk.
_SLOT_CHECKPOINT_FILES = {"gpu": "gpu_slot0.kv", "cpu": "cpu_slot0.kv"}


def slot_checkpoint_file(mode="gpu"):
    """Checkpoint filename (relative to ``LLAMA_SLOT_SAVE_DIR``) for ``mode``."""
    return _SLOT_CHECKPOINT_FILES.get(mode, _SLOT_CHECKPOINT_FILES["gpu"])


def slot_checkpoint_path(mode="gpu"):
    """Absolute path of the KV-checkpoint file for ``mode``."""
    return os.path.join(M.LLAMA_SLOT_SAVE_DIR, slot_checkpoint_file(mode))


def mark_slot_kv_dirty(mode="gpu"):
    """Flag the lane's slot KV as changed by an outgoing completion.

    Called right before any request is sent to ``mode``'s chat-completions
    endpoint — processing a prompt always mutates the server's slot KV. The
    flag decides whether :func:`save_slot_checkpoint` snapshots again on the
    next unload or the on-disk snapshot is already up to date."""
    with M._data_lock:
        M._slot_kv_dirty[mode] = True


def save_slot_checkpoint(mode="gpu"):
    """Snapshot the llama-server's current KV cache to disk.

    Calls ``POST /slots/0?action=save`` so the KV of everything processed so
    far survives an imminent model unload. Skipped when the model is not
    loaded or no completion has run since the last save/restore (the on-disk
    snapshot is already current). Failures (slot busy, media tokens in the
    slot, feature unavailable) never block the caller — they just mean the
    next reload re-prefills from scratch, exactly like before this
    optimization existed.
    """
    with M._data_lock:
        ms = M._cpu_model_status if mode == "cpu" else M.model_status
        dirty = M._slot_kv_dirty.get(mode, False)
        cp = M._slot_checkpoints.get(mode)
    if ms != "chat_loaded":
        return False
    if not dirty and cp:
        return True  # snapshot on disk already reflects the current KV

    filename = slot_checkpoint_file(mode)
    try:
        r = requests.post(
            f"{M.server_base(mode)}/slots/0?action=save",
            # "model" is required in router mode: the parent picks the child
            # instance to proxy to from this field (the child itself ignores it).
            json={"filename": filename, "model": M.server_model_id(mode)},
            timeout=180,
        )
        if r.status_code == 200:
            n_tokens = r.json().get("n_tokens", 0)
            with M._data_lock:
                M._slot_checkpoints[mode] = {
                    "file": filename,
                    "model": M.server_model_id(mode),
                    "ts": time.time(),
                    "n_tokens": n_tokens,
                }
                M._slot_kv_dirty[mode] = False
            print(
                f"[llama] {mode} slot KV checkpointed ({n_tokens} tokens) -> {filename}"
            )
            return True
        print(
            f"[llama] {mode} slot KV save failed ({r.status_code}): {r.text[:200]}"
        )
    except Exception as e:
        print(f"[llama] {mode} slot KV save error: {e}")
    return False


def restore_slot_checkpoint(mode="gpu"):
    """Restore the previously saved KV cache into slot 0 of ``mode``'s server.

    Called right after a model load. The restored prefix is only a *cache*:
    the next completion still verifies its prompt against the restored tokens
    and evaluates whatever is new, so a stale snapshot costs time but can
    never produce wrong output.
    """
    with M._data_lock:
        cp = dict(M._slot_checkpoints.get(mode) or {})
    if not cp:
        return False
    filename = cp.get("file") or slot_checkpoint_file(mode)
    if not os.path.exists(os.path.join(M.LLAMA_SLOT_SAVE_DIR, filename)):
        with M._data_lock:
            M._slot_checkpoints.pop(mode, None)
        return False
    if cp.get("model") != M.server_model_id(mode):
        # Snapshot belongs to another model — restoring it would fail (or worse,
        # misload state), drop it silently.
        print(
            f"[llama] Dropping stale {mode} KV checkpoint "
            f"(saved for '{cp.get('model')}', now '{M.server_model_id(mode)}')"
        )
        with M._data_lock:
            M._slot_checkpoints.pop(mode, None)
        return False

    try:
        r = requests.post(
            f"{M.server_base(mode)}/slots/0?action=restore",
            # See save_slot_checkpoint: router mode routes by body "model".
            json={"filename": filename, "model": M.server_model_id(mode)},
            timeout=180,
        )
        if r.status_code == 200:
            n_tokens = r.json().get("n_tokens", cp.get("n_tokens", 0))
            with M._data_lock:
                # The slot now holds exactly the snapshot's KV again.
                M._slot_kv_dirty[mode] = False
            print(
                f"[llama] {mode} slot KV restored from checkpoint ({n_tokens} tokens)"
            )
            return True
        # Unusable snapshot — clear it so we don't retry every load.
        print(
            f"[llama] {mode} slot KV restore failed ({r.status_code}): {r.text[:200]}"
        )
    except Exception as e:
        print(f"[llama] {mode} slot KV restore error: {e}")
    with M._data_lock:
        M._slot_checkpoints.pop(mode, None)
    return False


def _consult_worker(*args, **kwargs):
    # Implement worker call or redirect to your task handler
    pass


def consult_expert_model(prompt: str, mode: str = "cpu", **kwargs):
    """
    Executes a prompt against the expert/agent model pool.
    """
    from server.features.state import _llm_pools, _human_priority_active
    import time

    # Pause CPU execution if a human user is active
    if mode == "cpu":
        while _human_priority_active():
            time.sleep(1.0)

    # Submit task to the designated LLM thread pool
    pool = _llm_pools.get(mode, _llm_pools["cpu"])
    
    # Add your model invocation / API request logic here
    # future = pool.submit(your_llm_call_function, prompt, **kwargs)
    # return future.result()


def task_mode(task_id):
    """Return the llama-server mode a task must run on.

    Tasks posted by agent users (self-chat: editor, moderator, ...) run on the
    server selected by ``SELF_CHAT_MODE`` (``"cpu"`` or ``"gpu"``); tasks from
    interactive users always use the GPU server. A per-task ``mode`` override
    (set at /api/chat admission, e.g. by ``self-chat.py --gpu``) wins over the
    global flag for agent tasks. An interactive user who explicitly opted into
    the CPU lane for a research task (task marked ``cpu``) always runs on the
    CPU server. When ``FORCE_GPU_LANE`` is set (test-time), every non-flagged
    task — agent or not — runs on the GPU lane.
    """
    with M._data_lock:
        t = M.tasks.get(task_id)
        if not t:
            return "gpu"
        user = t.get("_user", "")
        mode = t.get("mode")
        cpu_flagged = bool(t.get("cpu"))
    if cpu_flagged:
        return "cpu"
    if M.FORCE_GPU_LANE:
        return "gpu"
    if user in M._agent_users and mode in ("gpu", "cpu"):
        return mode
    return M.SELF_CHAT_MODE if user in M._agent_users else "gpu"


def server_base(mode):
    """Base URL of the llama-server for ``mode`` (defaults to the GPU server)."""
    return M.LLAMA_BASE_CPU if mode == "cpu" else M.LLAMA_BASE


def server_url(mode):
    """Chat-completions URL of the llama-server for ``mode``."""
    return M.LLAMA_URL_CPU if mode == "cpu" else M.LLAMA_URL


def server_model_id(mode):
    """Model filename the llama-server for ``mode`` should load."""
    if mode == "cpu":
        return M.MODEL_ID_CPU or M.MODEL_ID
    return M.MODEL_ID


def server_status(mode):
    """Model status of the llama-server for ``mode`` ("unloaded", "loading",
    "chat_loaded", "unloading", ...)."""
    with M._data_lock:
        return M._cpu_model_status if mode == "cpu" else M.model_status


def server_last_use(mode):
    """Idle timestamp of the llama-server for ``mode``."""
    return M._cpu_last_llm_use if mode == "cpu" else M._last_llm_use


def active_model_id(mode="gpu"):
    """Backwards-compatible model filename lookup for ``mode``."""
    return server_model_id(mode)


def is_llama_alive(base=None):
    """True when the llama-server at ``base`` answers /health.

    Defaults to the GPU server so existing callers keep working.
    """
    if base is None:
        base = M.LLAMA_BASE
    try:
        r = requests.get(f"{base}/health", timeout=5)
        return r.status_code == 200
    except Exception:
        return False


def unload_llama_model(mode="gpu"):
    """Unload the model from the llama-server for ``mode``."""
    with M._model_transition_lock:
        with M._data_lock:
            if (M._cpu_model_status if mode == "cpu" else M.model_status) == "unloaded":
                return True

        print(f"[llama] Requesting {mode} model unload from VRAM/RAM...")
        # Checkpoint the KV cache BEFORE it is destroyed by the unload, so the
        # post-image-gen (or post-idle) reload can restore it instead of
        # re-prefilling the whole conversation. This must happen while the
        # model still reads "chat_loaded" — save_slot_checkpoint skips
        # anything else — hence before the "unloading" transition below.
        M.save_slot_checkpoint(mode)
        with M._data_lock:
            if mode == "cpu":
                M._cpu_model_status = "unloading"
            else:
                M.model_status = "unloading"

        try:
            r = requests.post(
                f"{M.server_base(mode)}/models/unload",
                json={"model": M.server_model_id(mode)},
                timeout=30,
            )
            if r.status_code == 200:
                print(f"[llama] {mode} model unloaded")
                with M._data_lock:
                    if mode == "cpu":
                        M._cpu_model_status = "unloaded"
                    else:
                        M.model_status = "unloaded"
                return True
            print(f"[llama] Unload response: {r.status_code} {r.text[:200]}")
        except Exception as e:
            print(f"[llama] Unload error: {e}")

        # Check real status if unload failed or erred out
        alive = M.is_llama_alive(M.server_base(mode))
        with M._data_lock:
            if mode == "cpu":
                M._cpu_model_status = "chat_loaded" if alive else "unloaded"
            else:
                M.model_status = "chat_loaded" if alive else "unloaded"
        return False


def load_llama_model(mode="gpu"):
    """Load the model on the llama-server for ``mode`` and wait for it to be
    ready, tracking the per-server model status and idle timestamp.

    When the load follows an unload (image generation, idle release), the KV
    checkpoint saved by :func:`save_slot_checkpoint` is restored so the next
    completion only has to evaluate new tokens."""
    with M._data_lock:
        # Only a fresh load benefits from a restore: if the model is already
        # running, its live KV is newer than any snapshot on disk.
        was_unloaded = (
            M._cpu_model_status if mode == "cpu" else M.model_status
        ) == "unloaded"
        if mode == "cpu":
            M._cpu_model_status = "loading"
        else:
            M.model_status = "loading"
    model_id = M.server_model_id(mode)
    base = M.server_base(mode)
    print(f"[llama] Sending load request for model '{model_id}' to {base}...")
    try:
        r = requests.post(
            f"{base}/models/load", json={"model": model_id}, timeout=180
        )
        if r.status_code in (200, 201):
            for i in range(30):
                if M.is_llama_alive(base):
                    print(f"[llama] {mode} model ready (attempt {i+1})")
                    with M._data_lock:
                        if mode == "cpu":
                            M._cpu_model_status = "chat_loaded"
                            M._cpu_last_llm_use = time.time()
                        else:
                            M.model_status = "chat_loaded"
                            M._last_llm_use = time.time()  # Reset idle timer upon loading
                    # Resume from the pre-unload KV checkpoint (no-op when
                    # there is none or the load wasn't a reload).
                    if was_unloaded:
                        M.restore_slot_checkpoint(mode)
                    return True
                time.sleep(2)
        else:
            print(f"[llama] Load failed ({r.status_code}): {r.text[:200]}")
    except Exception as e:
        print(f"[llama] Load exception: {e}")

    # Fallback check: verify if the server is alive and responding anyway
    if M.is_llama_alive(base):
        with M._data_lock:
            if mode == "cpu":
                M._cpu_model_status = "chat_loaded"
                M._cpu_last_llm_use = time.time()
            else:
                M.model_status = "chat_loaded"
                M._last_llm_use = time.time()  # Reset idle timer upon loading
        if was_unloaded:
            M.restore_slot_checkpoint(mode)
        return True

    with M._data_lock:
        if mode == "cpu":
            M._cpu_model_status = "unloaded"
        else:
            M.model_status = "unloaded"
    return False


def _inject_read_image(messages):
    """Attach the bytes of the most recent ``read_image`` result to its tool
    message so the model can actually see the image this round.

    The stored tool result stays a tiny JSON blob (url only); the image bytes
    are embedded only in this round's payload. A copy is returned so the
    stored session is never mutated.
    """
    out = list(messages)
    for i in range(len(out) - 1, -1, -1):
        m = out[i]
        if m.get("role") != "tool":
            continue
        content = m.get("content")
        if not isinstance(content, str):
            continue
        try:
            data = json.loads(content)
        except (TypeError, ValueError):
            continue
        url = data.get("image_url") if data.get("ok") is True else None
        if not url:
            continue
        data_url = M._image_to_data_url(url)
        if not data_url:
            continue
        out[i] = {
            **m,
            "content": [
                {"type": "text", "text": f"[Image loaded from {url}]"},
                {"type": "image_url", "image_url": {"url": data_url}},
            ],
        }
        break
    return out


def _llm_worker(task_id, sid, round_num, msgs, mode="gpu"):
    try:
        if M.estimate_tokens(msgs) > M.AUTO_COMPACT_THRESHOLD:
            M.set_status(task_id, "Context is full — compressing older messages...")
        messages = M.prepare_context_for_llm(sid, msgs, mode)
        messages = _inject_read_image(messages)
        tool_msgs = [m for m in messages if isinstance(m, dict) and m.get("role") == "tool"]
        if tool_msgs:
            print(f"[llm_round] Round {round_num} includes {len(tool_msgs)} tool message(s) with search results")  # DEBUG
        with M._data_lock:
            task_user = M.tasks.get(task_id, {}).get("_user", "")
            task_no_tools = M.tasks.get(task_id, {}).get("no_tools", False)
        tool_free = task_user in M.TOOL_FREE_AGENTS or task_no_tools
        payload = {
            "model": M.server_model_id(mode),
            "messages": messages,
            "tools": [] if tool_free else M.TOOLS,
            "tool_choice": "none" if tool_free else "auto",
            "max_tokens": M.MAX_INPUT_TOKENS,
            #"reasoning_budget": REASONING_BUDGET,
            #"reasoning_effort": "medium",
        }
        payload["stream"] = True
        M.mark_slot_kv_dirty(mode)
        r = requests.post(M.server_url(mode), json=payload, stream=True, timeout=600)
        if r.status_code != 200:
            err_body = r.text[:500] if r.text else f"HTTP {r.status_code}"
            raise RuntimeError(f"LLM server returned {r.status_code}: {err_body}")
        r.encoding = "utf-8"
        reasoning_buf = ""
        content_buf = ""
        tool_calls_map = {}
        with M._data_lock:
            prev_reasoning = M.tasks.get(task_id, {}).get("reasoning", "")
        for line in r.iter_lines(decode_unicode=True):
            if not line or not line.startswith("data: "):
                continue
            data_str = line[6:]
            if data_str.strip() == "[DONE]":
                break
            try:
                chunk = json.loads(data_str)
            except json.JSONDecodeError:
                continue
            choices = chunk.get("choices", [])
            if not choices:
                continue
            delta = choices[0].get("delta", {})
            rc = delta.get("reasoning_content")
            if rc:
                reasoning_buf += rc
                with M._data_lock:
                    if task_id in M.tasks:
                        M.tasks[task_id]["reasoning"] = prev_reasoning + reasoning_buf
            c = delta.get("content")
            if c:
                content_buf += c
            tc_list = delta.get("tool_calls")
            if tc_list:
                for tc in tc_list:
                    idx = tc.get("index", 0)
                    if idx not in tool_calls_map:
                        fn = tc.get("function", {})
                        tool_calls_map[idx] = {
                            "index": idx,
                            "id": tc.get("id", ""),
                            "type": tc.get("type", "function"),
                            "function": {
                                "name": fn.get("name", ""),
                                "arguments": fn.get("arguments", ""),
                            },
                        }
                    else:
                        existing = tool_calls_map[idx]
                        if tc.get("id"):
                            existing["id"] = tc["id"]
                        fn = tc.get("function")
                        if fn:
                            if fn.get("name"):
                                existing["function"]["name"] = fn["name"]
                            if fn.get("arguments"):
                                existing["function"]["arguments"] += fn["arguments"]
        print(f"[llm_round] Round {round_num} done: reasoning_buf={len(reasoning_buf)} chars, content_buf={len(content_buf)} chars, tool_calls={len(tool_calls_map)}")  # DEBUG
        msg = {
            "role": "assistant",
            "content": content_buf,
            "reasoning_content": prev_reasoning + reasoning_buf,
        }
        if tool_calls_map:
            msg["tool_calls"] = list(tool_calls_map.values())
        body = {"choices": [{"message": msg}]}
        if "choices" in body:
            M._event_post("llm_ok", task_id, body=body, round=round_num, sid=sid)
        else:
            M._event_post(
                "llm_err",
                task_id,
                error="Unexpected response",
                round=round_num,
                sid=sid,
            )
    except Exception as e:
        err_text = str(e)
        if "image" in err_text.lower() or "vision" in err_text.lower():
            err_text = "The current model does not support image input. Please use a vision-capable model or send text-only messages."
        M._event_post("llm_err", task_id, error=err_text, round=round_num, sid=sid)


def _start_llm_round(task_id, sid, round_num):
    mode = M.task_mode(task_id)
    M.ensure_llama_server(mode)
    with M._data_lock:
        ms = M._cpu_model_status if mode == "cpu" else M.model_status
    if ms != "chat_loaded":
        M.load_llama_model(mode)
    with M._data_lock:
        t = M.tasks.get(task_id)
        if not t:
            return
        t["_state"] = "llm_waiting"
        t["_round"] = round_num
        messages = list(M.sessions.get(sid, []))
    print(f"[llm_round] Starting round {round_num} for task {task_id} on {mode} server with {len(messages)} raw messages")  # DEBUG
    M.set_status(
        task_id, "Thinking..." if round_num == 0 else f"Thinking (round {round_num})..."
    )
    pool = M._llm_pools.get(mode, M._llm_pools["cpu"])
    pool.submit(M._llm_worker, task_id, sid, round_num, messages, mode)
</file>

<file path="server/features/sessions.py">
"""Conversation session persistence and per-request session preparation."""

import base64
import binascii
import glob
import json
import os
import re
import time
import uuid
from datetime import datetime

from server.features.state import M

# Injected into the system prompt only when the UI's "research" toggle is on.
RESEARCH_DIRECTIVE = """## Research Mode
You are performing deep, sourced research on the user's question.
- Plan: break the question into a few sub-questions/angles before answering.
- Gather: use web_search and fetch_page repeatedly. Fetch full pages and, when
  a page is long, read through it (a page may be returned in chunks).
- Cite: attach the exact source to every fact in EXACTLY the inline form
  `(Author, Venue, Year) [https://exact-page-url]` right at the claim. Use
  ROUND brackets (…) for the metadata and SQUARE brackets [url] for the URL.
  The metadata and the URL must both be present for EVERY factual claim. A
  citation with an empty or missing URL is strictly forbidden — never write
  `[...] []` or `(...) []`. Never cite a URL you did not actually open with
  fetch_page or see listed in a web_search result. Never reuse one URL as the
  support for many unrelated claims. If you are not certain about a metadata
  field, write "(Author, Venue, uncertain)" — never guess a year or author.
- Never invent: never write facts, sources, papers, or findings from memory or
  imagination and present them as researched. If you do not have a fetched
  source backing a claim, you do not have the claim yet.
- Resource failures are a signal to search MORE, not to improvise: if a fetch
  fails (403/404/timeout/blocked), re-search for the same article (mirrors,
  snippets, alternate hosts) and fetch again; keep searching and fetching new
  material until every claim is grounded in a source you actually opened. If a
  sub-answer genuinely has no findable source, state that it is UNSUPPORTED
  instead of fabricating support.
- Verify: cross-check important claims against more than one source.
- Conclude: answer only once the question is fully covered, then write a
  structured report (summary, findings with citations, limitations).
- Budget: you may keep searching/fetching for up to 50 rounds of tools, but
  stop as soon as the question is actually answered.
- Social Media & Unverified Content: Treat social media platforms 
  (X/Twitter, Reddit, forums, public blogs) strictly as anecdotal opinions or leads, 
  never as primary factual proof. Do not cite social media claims as verified facts 
  unless cross-checked and corroborated by an authoritative primary source 
 (official documentation, peer-reviewed study, or established publication)."""


def _session_file(user):
    return os.path.join(M.SESSIONS_DIR, f"sessions_{M._safe_username(user)}.json")


def _save_upload_image(image_b64, user=""):
    """Persist a base64-encoded upload to the uploads dir and return its URL.

    Keeps image bytes on disk instead of inside the conversation history, so
    sessions stay small and the LLM only receives the bytes for images it
    actually needs (see ``read_image`` / ``prepare_context_for_llm``).
    """
    if not image_b64:
        return None
    raw = base64.b64decode(image_b64)
    fname = f"{uuid.uuid4().hex}.jpg"
    os.makedirs(M.UPLOADS_DIR, exist_ok=True)
    fpath = os.path.join(M.UPLOADS_DIR, fname)
    with open(fpath, "wb") as f:
        f.write(raw)
    return f"/uploads/{fname}"


def _resolve_image_url(image, user=""):
    """Return the stored URL for a chat image that may be base64 or a link.

    The UI now uploads attached photos up front (``/api/upload-image``) and
    passes the resulting ``/uploads/...`` link with the chat request instead of
    embedding the raw base64. Accept either form so both old and new clients
    keep working: raw base64 blobs are written to disk, already-stored links
    and ``data:`` URLs are returned as-is.
    """
    if not image:
        return None
    s = str(image).strip()
    if s.startswith("data:image/"):
        s = s.split(",", 1)[-1]
    if s.startswith(("/uploads/", "/output/", "/api/image/")) or re.match(
        r"^https?://", s
    ):
        return s
    try:
        return _save_upload_image(s, user)
    except (ValueError, binascii.Error):
        pass
    return None


def _migrate_data_urls(messages):
    """Rewrite legacy ``data:image`` content parts to ``/uploads/`` file URLs.

    Older sessions stored uploaded images inline as base64 (a single session
    could reach ~19 MB). This writes those bytes to the uploads dir once and
    replaces the part URL, so the stored history stays small.
    """
    changed = False
    for msg in messages:
        content = msg.get("content")
        if not isinstance(content, list):
            continue
        parts = []
        for p in content:
            if isinstance(p, dict) and p.get("type") == "image_url":
                url = p.get("image_url", {}).get("url", "")
                if isinstance(url, str) and url.startswith("data:image"):
                    try:
                        b64 = url.split(",", 1)[-1]
                        new_url = _save_upload_image(b64, msg.get("_user", ""))
                    except (ValueError, binascii.Error):
                        new_url = None
                    if new_url:
                        parts.append({"type": "image_url", "image_url": {"url": new_url}})
                        changed = True
                        continue
            parts.append(p)
        if changed or len(parts) != len(content):
            msg["content"] = parts
    return changed


def _session_meta_from(sdata):
    return {
        "name": sdata.get("name", "Chat"),
        "created": sdata.get("created", time.time()),
        "updated": sdata.get("updated", time.time()),
        "user_id": sdata.get("user_id", ""),
        "system_prompts": sdata.get("system_prompts", []),
        "context_tokens": sdata.get("context_tokens", {}),
        "system_prompt": sdata.get("system_prompt", ""),
    }


def _load_extra_prompts(items):
    """Normalize a list of extra system prompt sources into [{name, content}].

    Each item may be a {name, content} dict or a server-side file path string.
    """
    blocks = []
    for it in items or []:
        if isinstance(it, dict):
            content = it.get("content") or ""
            if not content.strip():
                continue
            blocks.append(
                {"name": it.get("name") or "System Prompt", "content": content}
            )
        elif isinstance(it, str):
            p = os.path.abspath(os.path.expanduser(it))
            if os.path.isfile(p):
                try:
                    with open(p, "r", encoding="utf-8") as f:
                        blocks.append(
                            {"name": os.path.basename(it), "content": f.read()}
                        )
                except OSError:
                    pass
    return blocks


def load_sessions():
    os.makedirs(M.SESSIONS_DIR, exist_ok=True)
    with M._data_lock:
        M.sessions.clear()
        M.sessions_meta.clear()
    migrated = False
    for path in glob.glob(os.path.join(M.SESSIONS_DIR, "sessions_*.json")):
        try:
            with open(path) as f:
                data = json.load(f)
        except (FileNotFoundError, json.JSONDecodeError):
            continue
        with M._data_lock:
            for sid, sdata in data.get("sessions", {}).items():
                msgs = sdata.get("messages", [])
                if _migrate_data_urls(msgs):
                    migrated = True
                M.sessions[sid] = msgs
                M.sessions_meta[sid] = _session_meta_from(sdata)
    stale = os.path.join(M.SESSIONS_DIR, "sessions.json")
    if os.path.exists(stale):
        try:
            with open(stale) as f:
                data = json.load(f)
            with M._data_lock:
                for sid, sdata in data.get("sessions", {}).items():
                    if sid not in M.sessions:
                        msgs = sdata.get("messages", [])
                        if _migrate_data_urls(msgs):
                            migrated = True
                        M.sessions[sid] = msgs
                        M.sessions_meta[sid] = _session_meta_from(sdata)
        except (FileNotFoundError, json.JSONDecodeError):
            pass
        try:
            os.remove(stale)
        except OSError:
            pass
    if migrated:
        save_sessions()


def save_sessions():
    os.makedirs(M.SESSIONS_DIR, exist_ok=True)
    by_user = {}
    with M._data_lock:
        for sid in M.sessions:
            meta = M.sessions_meta.get(
                sid, {"name": "Chat", "created": time.time(), "updated": time.time()}
            )
            user = meta.get("user_id", "")
            by_user.setdefault(user, {}).setdefault("sessions", {})[sid] = {
                "name": meta["name"],
                "created": meta["created"],
                "updated": meta["updated"],
                "user_id": meta.get("user_id", ""),
                "system_prompts": meta.get("system_prompts", []),
                "context_tokens": meta.get("context_tokens", {}),
                "system_prompt": meta.get("system_prompt", ""),
                "messages": M.sessions[sid],
            }
    for user, data in by_user.items():
        with open(_session_file(user), "w") as f:
            json.dump(data, f, indent=2)


def _prepare_session(task_id, sid, user_message, image_b64, audio_b64=None, client_ts=None):
    try:
        if client_ts:
            ts = datetime.fromisoformat(client_ts.replace("Z", "+00:00"))
        else:
            ts = datetime.now()
    except Exception:
        ts = datetime.now()
    loc = M.location_str()
    loc_context = f" [User location: {loc}]" if loc else ""
    date_loc_context = f"[Current date: {ts.strftime('%Y-%m-%d %A %H:%M')}]{loc_context}"
    user = ""
    extra_prompts = []
    context_tokens = {}
    system_prompt = ""
    with M._data_lock:
        t = M.tasks.get(task_id)
        if t:
            user = t.get("_user", "")
        meta = M.sessions_meta.get(sid, {})
        extra_prompts = meta.get("system_prompts", [])
        context_tokens = meta.get("context_tokens", {})
        system_prompt = meta.get("system_prompt", "")
    user_context = M.read_user_context(user) if user else ""
    context_block = f"\n\n## User Context\n{user_context}" if user_context else ""
    # A session created with its own system prompt (e.g. a self-chat agent
    # directive) uses it as the base instead of the global sys_prompt.txt.
    base_sys = system_prompt if system_prompt else M.SYS_CONTENT
    full_sys_content = f"{base_sys}\n\n{date_loc_context}{context_block}"
    for blk in extra_prompts:
        full_sys_content += f"\n\n## {blk.get('name', 'System Prompt')}\n{blk.get('content', '')}"
    with M._data_lock:
        if M.tasks.get(task_id, {}).get("research"):
            full_sys_content += f"\n\n{RESEARCH_DIRECTIVE}"
    full_sys_content = full_sys_content.replace(
        "%current_time%", ts.strftime("%Y-%m-%d %A %H:%M")
    )
    if loc:
        full_sys_content = full_sys_content.replace("%current_location%", loc)
    else:
        full_sys_content = full_sys_content.replace(
            "Currently the server is hosted on %current_location%.", ""
        )
        full_sys_content = full_sys_content.replace("%current_location%", "not available")
    for token, value in context_tokens.items():
        full_sys_content = full_sys_content.replace(token, value)
    image_url = _resolve_image_url(image_b64, user)
    if user_context:
        print(
            f"[context] Injected {len(user_context)} chars of context for user '{user}'"
        )
    with M._data_lock:
        if sid not in M.sessions or not M.sessions[sid]:
            M.sessions[sid] = [{"role": "system", "content": full_sys_content}]
        elif M.sessions[sid][0].get("role") == "system":
            M.sessions[sid][0]["content"] = full_sys_content
        else:
            M.sessions[sid].insert(0, {"role": "system", "content": full_sys_content})
        if sid not in M.sessions_meta:
            M.sessions_meta[sid] = {
                "name": user_message[:50],
                "created": time.time(),
                "updated": time.time(),
            }
        content = []
        if image_url:
            content.append(
                {
                    "type": "image_url",
                    "image_url": {"url": image_url},
                }
            )
        if audio_b64:
            content.append({"type": "text", "text": "\U0001F3A4 Audio message"})
        content.append(
            {
                "type": "text",
                "text": user_message,
            }
        )
        M.sessions[sid].append(
            {
                "role": "user",
                "content": content,
                "_timestamp": datetime.now().isoformat(),
                "_research": bool(M.tasks.get(task_id, {}).get("research")),
            }
        )
        if M.sessions_meta[sid]["name"] in ("New Chat", ""):
            M.sessions_meta[sid]["name"] = user_message[:50] + (
                "..." if len(user_message) > 50 else ""
            )
        M.sessions_meta[sid]["updated"] = time.time()
    M.save_sessions()
    mode = M.task_mode(task_id)
    with M._data_lock:
        ms = M._cpu_model_status if mode == "cpu" else M.model_status
    if ms != "chat_loaded":
        M.load_llama_model(mode)
</file>

<file path="server/features/state.py">
"""Shared application state and the entrypoint proxy.

The chat engine's implementation lives in the :mod:`server.features` package,
but the entrypoint module (``chat-webui.py``) remains the single owner of every
shared value: containers, scalar flags, constants, config values and the
cross-cutting helpers. Feature modules resolve those names at *call time*
through the ``M`` proxy registered here, which simply forwards attribute reads
and writes to the entrypoint module.

This indirection is what keeps per-test monkeypatching working. The test-suite
patches ``chat-webui.<name>`` (functions, containers, scalars, stdlib modules)
and expects every feature module to observe those patches, so no feature module
may bind a shared name at import time.
"""

import queue as _queue
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from server.config import CPU_PARALLEL_SLOTS

class _Registry:
    """Holds the single entrypoint module reference."""

    entrypoint = None


def register_entrypoint(module):
    """Point the ``M`` proxy at the module that owns the shared state."""
    _Registry.entrypoint = module


class _Proxy:
    """Forward attribute access to the registered entrypoint module."""

    def __getattr__(self, name):
        ep = _Registry.entrypoint
        if ep is None:
            raise AttributeError(
                f"no entrypoint registered yet; import chat-webui.py before using {name}"
            )
        return getattr(ep, name)

    def __setattr__(self, name, value):
        _Registry.entrypoint.__setattr__(name, value)


M = _Proxy()

# ---------------------------------------------------------------------------
# In-memory application state (the same objects chat-webui.py re-exports)
# ---------------------------------------------------------------------------

sessions = {}
sessions_meta = {}
tasks = {}
shares = {}

_active_tokens = {}
_tokens_lock = threading.Lock()
_agent_tokens = set()
_agent_users = set()

# Username → last-activity timestamp for SSO/header-authenticated clients.
# With Authentik fronting the browser there is no per-request token anymore;
# the browser's 2s model-status poll keeps touching this seat as the heartbeat.
_user_last_seen = {}
_user_last_seen_lock = threading.Lock()

# A human user counts as "active" (blocking self-chat agents) while any of
# their requests has been seen within this window. The browser's 2s model-status
# poll acts as the heartbeat.
ACTIVE_WINDOW_SECONDS = 120

_effective_contexts = {}
_effective_contexts_lock = threading.Lock()

_model_transition_lock = threading.Lock()
_data_lock = threading.Lock()

MAX_QUEUE_SIZE = 5

# Tool-loop budget per task. Normal chats stay light (10 LLM rounds ≈ small
# number of tool calls); tasks sent with the UI's "research" toggle get the
# deep recursive budget so the agent can chunk-walk pages and re-search until
# the question is answered.
MAX_TOOL_ROUNDS = {"default": 10, "research": 50}

# Two independent task lanes so interactive UI (GPU) users and self-chat
# agents (CPU) never queue behind each other. Each lane has its own list,
# lock/condition and "currently running" marker. Only image-generation VRAM
# choreography (see _image_queue in images.py) stays globally serialized,
# since ComfyUI only has one physical GPU to render on regardless of which
# lane requested the image.
_task_queues = {"gpu": [], "cpu": []}
_queue_locks = {"gpu": threading.Lock(), "cpu": threading.Lock()}
_queue_conds = {mode: threading.Condition(lock) for mode, lock in _queue_locks.items()}
_current_task_ids = {"gpu": None, "cpu": None}

_event_queue = _queue.Queue()
# Serializes image generation/editing so VRAM management (llama unload/free/load)
# and the ``image_active`` model status never race between concurrent chats.
_image_queue = _queue.Queue()
# One LLM-round pool and one tool-call pool PER LANE. These used to be single
# shared pools (_llm_pool max_workers=1, _tool_pool max_workers=2) — even after
# splitting task admission into gpu/cpu lanes, actually *running* a round or a
# tool call still funneled through those single shared pools, so a GPU (UI)
# task and a CPU (agent) task would still block on each other's turn in the
# pool. Splitting per-lane makes them genuinely independent end-to-end.
_llm_pools = {"gpu": ThreadPoolExecutor(max_workers=1), "cpu": ThreadPoolExecutor(max_workers=CPU_PARALLEL_SLOTS)}
_tool_pools = {"gpu": ThreadPoolExecutor(max_workers=2), "cpu": ThreadPoolExecutor(max_workers=2)}

_location_events = {}

# Scalars the engine reads and rebinds at runtime.
#
# There are TWO llama-servers running concurrently: the GPU server on 8081
# serves interactive chat UI users, and the CPU server on 8079 serves automated
# self-chat agents. Each keeps its own model status and idle timestamp.
model_status = "unloaded"
_cpu_model_status = "unloaded"
_last_tps = None
_last_llm_use = time.time()
_cpu_last_llm_use = time.time()

# KV-cache slot checkpoints per llama-server lane (see llm.py). Maps mode →
# {"file": str, "model": str, "ts": float, "n_tokens": int} describing the
# last successfully saved /slots/{id}?action=save snapshot, which is restored
# after the model loads again so the conversation KV is not re-prefilled.
_slot_checkpoints = {}
# Per-lane flag set whenever a completion reaches a llama-server (its slot KV
# changed) and cleared once that KV is captured by save/restore. Gates whether
# an unload snapshots the slot again.
_slot_kv_dirty = {"gpu": False, "cpu": False}
_client_location = None
_overheated = False
_gpu_temp = None
_ram_evacuating = False
_users_cache = None
_users_cache_time = 0

# Context / token budget constants.
# The interactive UI chat runs on the GPU llama-server, which is launched with
# --ctx-size 24576 (24K). Keep this in sync with server/config.py so the UI's
# context meter and the /api/model-status payload reflect the real budget.
MAX_INPUT_TOKENS = 24576
AUTO_COMPACT_THRESHOLD = int(MAX_INPUT_TOKENS * 0.7)

# Monitoring constants.
TEMP_THRESHOLD_ON = 90
TEMP_THRESHOLD_OFF =75
RAM_EVAC_THRESHOLD = 95
RAM_RESUME_THRESHOLD = 70
</file>

<file path="markdown_hosting.py">
import os
import json
import html
import shutil
import markdown
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.exception_handlers import http_exception_handler
from fastapi.responses import HTMLResponse, FileResponse
from urllib.parse import quote

from server.dotenv import load_dotenv

load_dotenv()

app = FastAPI()

BASE_STORIES_DIR = "./stories"

# Directory roots resolved from environment variables.
# Hierarchy: everyone -> free dir, premium +1 dir, admin +1 more dir.
# PREMIUM and ADMIN dirs are REQUIRED — markdown hosting fails fast if missing.
_FREE_DIR = os.getenv("STORIES_FREE_DIR", os.path.expanduser("~/local-ai-files/stories"))
_PREMIUM_DIR = os.getenv("STORIES_PREMIUM_DIR", "")
_ADMIN_DIR = os.getenv("STORIES_ADMIN_DIR", "")

for _var, _val in (("STORIES_PREMIUM_DIR", _PREMIUM_DIR), ("STORIES_ADMIN_DIR", _ADMIN_DIR)):
    if not _val:
        raise RuntimeError(
            f"markdown_hosting.py cannot start: required environment variable "
            f"{_var} is not set."
        )

# Each collection requires a minimum role level to access.
ROLE_LEVEL = {
    "guest": 0,
    "free": 0,
    "user": 0,
    "premium": 1,
    "admin": 2,
}

COLLECTION_RULES = {
    "free_stories": {"path": _FREE_DIR, "min_level": 0},     # everyone
    "premium_stories": {"path": _PREMIUM_DIR, "min_level": 1},  # free + premium
    "admin_stories": {"path": _ADMIN_DIR, "min_level": 2},    # free + premium + admin
}


# --- Auth & RBAC Helpers (shared Authentik-backed identity) ---

def get_current_user(request: Request) -> str | None:
    """Username authenticated through nginx's auth_request (X-Authentik-*).

    Direct localhost callers may present ``Authorization: Bearer <jwt>``; that
    is verified against Authentik's JWKS (see server/auth.py). Returns None
    when no identity is present.
    """
    from server.auth import get_current_user as _get_current_user
    return _get_current_user(request.headers)


def get_current_role(request: Request) -> str:
    """Role (free/premium/admin) derived from Authentik group membership."""
    from server.auth import get_identity as _get_identity
    identity = _get_identity(request.headers)
    return identity["role"] if identity else "free"


def user_role_level(username: str | None, role: str | None = None) -> int:
    """Map a username (or resolved role) to its hierarchy level (0=guest ... 2=admin)."""
    if role is None:
        if not username:
            return ROLE_LEVEL["guest"]
        return ROLE_LEVEL.get(get_user_role(username), ROLE_LEVEL["free"])
    return ROLE_LEVEL.get(role, ROLE_LEVEL["free"])


def get_user_role(username: str) -> str:
    """Resolve role from the shared identity provider's group membership.

    Kept for backward compatibility; the chat server derives role from
    X-Authentik-Groups via server/auth.py.
    """
    from server.auth import role_from_groups
    return role_from_groups(_stories_role_groups_for(username))


def _stories_role_groups_for(username: str) -> list:
    """Best-effort group mapping for a username seen without claim headers.

    Legacy fallback so direct localhost requests (no nginx auth_request) still
    get a sensible role. Once nginx fronts the service this path is never hit.
    """
    if username in {"palash"}:
        return ["admin"]
    if username in {"totan"}:
        return ["premium"]
    return ["free"]


def enforce_rbac(collection_folder: str, request: Request | None = None, username: str | None = None):
    """Checks the user's role against the collection's minimum required level."""
    rule = COLLECTION_RULES.get(collection_folder)
    if not rule:
        raise HTTPException(status_code=404, detail="Collection not found")

    if request is not None:
        level = user_role_level(None, get_current_role(request))
        authed = bool(get_current_user(request))
    else:
        authed = bool(username)
        level = user_role_level(username)

    min_level = rule["min_level"]
    if level < min_level:
        if not authed and min_level > 0:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Authentication required.",
            )
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Access Denied: higher subscription/role required for this story collection.",
        )


def error_page(status_code: int, detail: str) -> str:
    """Render a styled HTML error page for browser navigation.

    API and live-polling endpoints keep their JSON error responses (see
    ``_http_exception_html_handler``); anything a user would navigate to in the
    browser gets a proper page instead of raw JSON. Unauthenticated browser
    traffic never reaches here anonymously in production — nginx runs
    ``auth_request`` against the Authentik proxy outpost, so a 401 means the
    Authentik session simply needs to be established.
    """
    titles = {
        401: "Authentication Required",
        403: "Access Denied",
        404: "Not Found",
    }
    title = titles.get(status_code, f"Error {status_code}")
    if status_code == 401:
        message = "Please log in to view this story collection."
    else:
        message = html.escape(str(detail))
    login_block = """
    <span class="login-toggle"><a href="/sso/outpost.goauthentik.io/start?rd=%2Fstories%2F">Sign in with SSO</a></span>
    """
    return f"""
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>{title}</title>
        <style>
            * {{ box-sizing: border-box; }}
            html {{ -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }}
            body {{ font-family: Georgia, 'Times New Roman', serif; font-size: 18px; max-width: 40em; margin: 0 auto; padding: 16px; line-height: 1.7; background: #fafafa; color: #111; }}
            h1 {{ color: #c44; }}
            a.back {{ color: #666; text-decoration: none; }}
            .topbar {{ display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; font-family: sans-serif; font-size: 14px; }}
            .topbar .login {{ display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }}
            .topbar .login.hidden {{ display: none; }}
            .topbar .login-toggle a {{ color: #06c; font-weight: bold; }}
            .topbar input {{ padding: 6px 8px; border: 1px solid #aaa; border-radius: 6px; font-size: 14px; background: #fff; color: #111; }}
            .topbar button {{ background: none; border: 1px solid #888; color: #888; border-radius: 6px; padding: 5px 12px; cursor: pointer; font-family: sans-serif; font-size: 13px; }}
            .topbar button:hover {{ background: #eee; }}
            .topbar button.primary {{ background: #06c; color: #fff; border-color: #06c; }}
            .topbar button.primary:hover {{ background: #0577e6; }}
            .topbar #login-msg {{ color: #c44; font-size: 12px; width: 100%; }}
            @media (max-width: 600px) {{
                body {{ padding: 12px; font-size: 19px; }}
                .topbar {{ flex-direction: column; align-items: stretch; }}
                .topbar .login {{ flex-direction: column; align-items: stretch; }}
                .topbar input {{ width: 100%; }}
            }}
            @media (prefers-color-scheme: dark) {{
                body {{ background: #16181d; color: #e6e6e6; }}
                h1 {{ color: #ff8080; }}
                a.back {{ color: #999; }}
                .topbar .login-toggle a {{ color: #7ab8ff; }}
                .topbar input {{ background: #1f232b; border-color: #3a3f4a; color: #e6e6e6; }}
                .topbar input::placeholder {{ color: #888; }}
                .topbar button {{ background: #1f232b; border-color: #555; color: #cfcfcf; }}
                .topbar button:hover {{ background: #262b34; }}
                .topbar button.primary {{ background: #3a7ee0; border-color: #3a7ee0; color: #fff; }}
                .topbar button.primary:hover {{ background: #4a8cec; }}
                .topbar #login-msg {{ color: #ff8080; }}
            }}
        </style>
    </head>
    <body>
        <nav class="topbar">
            <a href="/" class="back">← Back to Collections</a>
            {login_block}
        </nav>
        <h1>{title}</h1>
        <p>{message}</p>
    </body>
    </html>
    """


@app.exception_handler(HTTPException)
async def _http_exception_html_handler(request: Request, exc: HTTPException):
    """JSON errors for API/live-poll endpoints, HTML pages for browser routes."""
    path = request.url.path
    if path.startswith("/api/") or path.endswith("/content"):
        return await http_exception_handler(request, exc)
    return HTMLResponse(
        status_code=exc.status_code,
        content=error_page(exc.status_code, exc.detail),
    )


# --- Authentication Endpoints ---

# Browser authentication is handled entirely by nginx's auth_request against
# the Authentik proxy outpost (/sso/...). The endpoints that previously issued
# self-managed X-Auth-Token cookies no longer exist: identity comes from the
# X-Authentik-* claim headers nginx forwards upstream.


# --- Dynamic Story Engine & Media Router ---

def pick_story_md(folder_path):
    """Prefer the editor's revised file (story_rN_ts.edited.md) over the original."""
    mds = [f for f in os.listdir(folder_path) if f.endswith(".md")]
    if not mds:
        return None
    edited = [f for f in mds if f.endswith(".edited.md")]
    return os.path.join(folder_path, (edited or mds)[0])


def story_moderation(folder_path):
    """Return the moderator verdict dict for a story, or None."""
    for f in os.listdir(folder_path):
        if f.endswith(".moderation.json"):
            try:
                with open(os.path.join(folder_path, f), encoding="utf-8") as fh:
                    return json.load(fh)
            except (OSError, json.JSONDecodeError):
                return None
    return None


def moderation_badge(mod):
    """HTML snippet showing the GREEN/RED verdict, or empty string.

    For RED verdicts, includes the moderator's reason as a tap/hover tooltip
    (see .mod-badge CSS/JS shared by both pages).
    """
    if not mod:
        return ""
    v = mod.get("verdict", "")
    color = "#2a7" if v == "GREEN" else ("#c44" if v == "RED" else "#888")
    if v == "RED":
        reason = html.escape(mod.get("reasons") or mod.get("reason") or "No reason provided.")
        return (
            f' <span class="mod-badge" tabindex="0" data-reason="{reason}" '
            f'style="color:{color}; font-size:11px; font-family:sans-serif; '
            f'cursor:pointer; border-bottom:1px dotted {color};">({v})</span>'
        )
    return (
        f' <span style="color:{color}; font-size:11px; font-family:sans-serif;">'
        f"({v})</span>"
    )


def list_collection_stories(root: str):
    """Return [(genre_label | None, story_id)] for a collection root.

    Legacy flat story folders (md directly inside root) are reported with
    genre None; genre folders contain story subdirectories.
    """
    entries = sorted(
        os.listdir(root),
        key=lambda e: os.path.getmtime(os.path.join(root, e)),
        reverse=True,
    )
    items = []
    for entry in entries:
        full = os.path.join(root, entry)
        if not os.path.isdir(full):
            continue
        if any(f.endswith(".md") for f in os.listdir(full)):
            items.append((None, entry))
            continue
        for sub in sorted(os.listdir(full), reverse=True):
            subfull = os.path.join(full, sub)
            if os.path.isdir(subfull) and any(
                f.endswith(".md") for f in os.listdir(subfull)
            ):
                items.append((entry, os.path.join(entry, sub)))
    return items


@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
    """Collections index listing available story collections and their stories."""
    username = get_current_user(request)
    role = get_current_role(request)
    user_level = user_role_level(username, role)
    cards = []
    for name, rule in COLLECTION_RULES.items():
        if rule["min_level"] > user_level:
            continue
        root = rule["path"]
        if not os.path.isdir(root):
            continue
        items = list_collection_stories(root)
        if not items:
            continue
        grouped = {}
        for genre, sid in items:
            grouped.setdefault(genre, []).append(sid)
        sections = []
        for genre, sids in grouped.items():
            heading = f"<h3>{genre.replace('_', ' ').title()}</h3>" if genre else ""
            lis = []
            for sid in sids:
                badge = moderation_badge(story_moderation(os.path.join(root, sid)))
                
                # Safely quote path segments for URLs and escape HTML text display
                encoded_sid = "/".join(quote(part) for part in sid.split("/"))
                display_name = html.escape(sid.split("/")[-1])
                
                lis.append(
                    f'<li><a href="/story/{name}/{encoded_sid}">{display_name}</a>{badge}</li>'
                )
            sections.append(heading + "<ul>" + "".join(lis) + "</ul>")
        cards.append(
            f"<h2>{name.replace('_', ' ').title()}</h2>" + "".join(sections)
        )
    body = "".join(cards) or "<p>No story collections found yet.</p>"
    if username:
        auth_html = f"""
        <span class="logged">Logged in as <strong>{username}</strong> ({role})</span>
        <button id="logout-btn">Log out</button>
        """
    else:
        auth_html = """
        <span class="login-toggle"><a href="/sso/outpost.goauthentik.io/start?rd=%2Fstories%2F">Sign in with SSO</a></span>
        """
    return f"""
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Story Collections</title>
        <style>
            * {{ box-sizing: border-box; }}
            html {{ -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }}
            body {{ font-family: Georgia, 'Times New Roman', serif; font-size: 18px; max-width: 40em; margin: 0 auto; padding: 16px; line-height: 1.7; background: #fafafa; color: #111; }}
            h1, h2, h3 {{ color: #333; line-height: 1.3; }}
            ul {{ margin: 0 0 1.2em; padding-left: 1.4em; }}
            li {{ margin-bottom: 0.6em; }}
            a {{ color: #06c; text-decoration: none; }}
            .topbar {{ display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 20px; font-family: sans-serif; font-size: 14px; }}
            .topbar .logged {{ margin: 0; color: #555; }}
            .topbar .login {{ display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }}
            .topbar .login.hidden {{ display: none; }}
            .topbar .login-toggle a {{ color: #06c; font-weight: bold; }}
            .topbar input {{ padding: 6px 8px; border: 1px solid #aaa; border-radius: 6px; font-size: 14px; background: #fff; color: #111; }}
            .topbar button {{ background: none; border: 1px solid #888; color: #888; border-radius: 6px; padding: 5px 12px; cursor: pointer; font-family: sans-serif; font-size: 13px; }}
            .topbar button:hover {{ background: #eee; }}
            .topbar button.primary {{ background: #06c; color: #fff; border-color: #06c; }}
            .topbar button.primary:hover {{ background: #0577e6; }}
            .topbar #login-msg {{ color: #c44; font-size: 12px; width: 100%; }}
            .mod-badge {{ position: relative; }}
            .mod-badge:hover::after, .mod-badge.show-tip::after {{
                content: attr(data-reason);
                position: absolute; left: 0; top: 100%; margin-top: 4px;
                background: #222; color: #fff; padding: 6px 10px; border-radius: 6px;
                font-size: 12px; line-height: 1.4; white-space: normal;
                width: max-content; max-width: 240px; z-index: 10;
            }}
            @media (max-width: 600px) {{
                body {{ padding: 12px; font-size: 19px; }}
                .topbar {{ flex-direction: column; align-items: stretch; }}
                .topbar .login {{ flex-direction: column; align-items: stretch; }}
                .topbar input {{ width: 100%; }}
            }}
            @media (prefers-color-scheme: dark) {{
                body {{ background: #16181d; color: #e6e6e6; }}
                h1, h2, h3 {{ color: #f0f0f0; }}
                a {{ color: #7ab8ff; }}
                .topbar .logged {{ color: #aaa; }}
                .topbar .login-toggle a {{ color: #7ab8ff; }}
                .topbar input {{ background: #1f232b; border-color: #3a3f4a; color: #e6e6e6; }}
                .topbar input::placeholder {{ color: #888; }}
                .topbar button {{ background: #1f232b; border-color: #555; color: #cfcfcf; }}
                .topbar button:hover {{ background: #262b34; }}
                .topbar button.primary {{ background: #3a7ee0; border-color: #3a7ee0; color: #fff; }}
                .topbar button.primary:hover {{ background: #4a8cec; }}
                .topbar #login-msg {{ color: #ff8080; }}
            }}
        </style>
    </head>
    <body>
        <nav class="topbar">{auth_html}</nav>
        <h1>Story Collections</h1>
        {body}
        <script>
            const logoutBtn = document.getElementById('logout-btn');
            if (logoutBtn) {{
                logoutBtn.addEventListener('click', () => {{
                    window.location.href = '/outpost.goauthentik.io/sign_out?rd=%2Fstories%2F';
                }});
            }}
            // Tap-to-toggle tooltip for moderation badges (title= doesn't work on mobile touch).
            document.querySelectorAll('.mod-badge').forEach(el => {{
                el.addEventListener('click', e => {{
                    e.stopPropagation();
                    document.querySelectorAll('.mod-badge.show-tip').forEach(o => {{
                        if (o !== el) o.classList.remove('show-tip');
                    }});
                    el.classList.toggle('show-tip');
                }});
            }});
            document.addEventListener('click', () => {{
                document.querySelectorAll('.mod-badge.show-tip').forEach(o => o.classList.remove('show-tip'));
            }});
        </script>
    </body>
    </html>
    """
@app.get("/media/{collection}/{story_id:path}/{filename}")
async def serve_story_image(
    collection: str, 
    story_id: str, 
    filename: str, 
    request: Request
):
    """Serves media dynamically while checking the authenticated session."""
    enforce_rbac(collection, request=request)
    
    root = COLLECTION_RULES[collection]["path"]
    file_path = os.path.join(root, story_id, filename)
    if not os.path.exists(file_path):
        raise HTTPException(status_code=404, detail="Image not found")
        
    return FileResponse(file_path)


def render_story_html(collection: str, story_id: str, content: str) -> str:
    """Render story markdown and rewrite image srcs to the authenticated media route.

    pymdownx.arithmatex (generic mode) leaves $...$ / $$...$$ math untouched
    but wraps it in <span class="arithmatex"> / <div class="arithmatex">
    so the KaTeX auto-render script loaded on the story page can find and
    typeset it client-side.
    """
    html_content = markdown.markdown(
        content,
        extensions=['extra', 'tables', 'fenced_code', 'pymdownx.arithmatex'],
        extension_configs={'pymdownx.arithmatex': {'generic': True}},
    )
    html_content = html_content.replace(
        '<table>', '<div class="table-wrap"><table>'
    ).replace('</table>', '</table></div>')
    return html_content.replace('src="', f'src="/media/{collection}/{story_id}/')


@app.get("/story/{collection}/{story_id:path}/content")
async def story_content(
    collection: str, 
    story_id: str, 
    request: Request
):
    """Returns the current rendered story HTML for live polling."""
    enforce_rbac(collection, request=request)

    folder_path = os.path.join(COLLECTION_RULES[collection]["path"], story_id)
    if not os.path.exists(folder_path):
        raise HTTPException(status_code=404, detail="Story folder not found")

    md_file = pick_story_md(folder_path)
    if not md_file:
        raise HTTPException(status_code=404, detail="No markdown file found in story directory")

    with open(md_file, 'r', encoding='utf-8') as f:
        content = f.read()

    return {"html": render_story_html(collection, story_id, content)}


@app.delete("/story/{collection}/{story_id:path}")
async def delete_story(
    collection: str, 
    story_id: str, 
    request: Request
):
    """Deletes the story folder (markdown + images). Admin role required."""
    enforce_rbac(collection, request=request)

    if get_current_role(request) != "admin":
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Admin role required to delete stories.",
        )

    folder_path = os.path.join(COLLECTION_RULES[collection]["path"], story_id)
    if not os.path.exists(folder_path):
        raise HTTPException(status_code=404, detail="Story folder not found")

    shutil.rmtree(folder_path, ignore_errors=True)
    if os.path.exists(folder_path):
        raise HTTPException(status_code=500, detail="Failed to delete story folder")
    return {"ok": True, "deleted": story_id}


@app.get("/story/{collection}/{story_id:path}", response_class=HTMLResponse)
async def read_story(
    collection: str, 
    story_id: str, 
    request: Request
):
    """Reads story Markdown dynamically and enforces access controls."""
    enforce_rbac(collection, request=request)
    
    folder_path = os.path.join(COLLECTION_RULES[collection]["path"], story_id)
    if not os.path.exists(folder_path):
        raise HTTPException(status_code=404, detail="Story folder not found")
        
    md_file = pick_story_md(folder_path)
    if not md_file:
        raise HTTPException(status_code=404, detail="No markdown file found in story directory")
        
    with open(md_file, 'r', encoding='utf-8') as f:
        content = f.read()

    html_content = render_story_html(collection, story_id, content)
    is_admin = get_current_role(request) == "admin"

    verdict_html = ""
    mod = story_moderation(folder_path)
    if mod:
        v = mod.get("verdict", "")
        color = "#2a7" if v == "GREEN" else ("#c44" if v == "RED" else "#888")
        if v == "RED":
            reason = html.escape(mod.get("reasons") or mod.get("reason") or "No reason provided.")
            verdict_html = (
                f'<div class="mod-badge" tabindex="0" data-reason="{reason}" '
                f'style="font-family:sans-serif; color:{color}; font-size:13px; '
                f'margin-bottom:12px; display:inline-block; cursor:pointer; '
                f'border-bottom:1px dotted {color};">Moderation: {v} (tap for reason)</div>'
            )
        else:
            verdict_html = (
                f'<div style="font-family:sans-serif; color:{color}; font-size:13px; '
                f'margin-bottom:12px;">Moderation: {v}</div>'
            )

    delete_button_html = '<button id="delete-btn">Delete story</button>' if is_admin else ""

    # Escaped parameters for safe JavaScript injection and HTTP URL generation
    story_id_js = json.dumps(story_id)
    encoded_collection = quote(collection)
    encoded_story_path = "/".join(quote(part) for part in story_id.split("/"))

    return f"""
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>{html.escape(story_id.split('/')[-1].replace('-', ' ').title())}</title>
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
        <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
        <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script>
        <style>
            * {{ box-sizing: border-box; }}
            html {{ -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }}
            body {{ font-family: Georgia, 'Times New Roman', serif; font-size: 18px; max-width: 40em; margin: 0 auto; padding: 16px; line-height: 1.7; background: #fafafa; color: #111; }}
            article {{ overflow-wrap: break-word; }}
            article p, article li {{ font-size: 1em; }}
            img {{ max-width: 100%; height: auto; border-radius: 8px; margin: 20px 0; display: block; }}
            .topbar {{ display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; font-family: sans-serif; font-size: 14px; }}
            a.back {{ color: #666; text-decoration: none; }}
            .topbar button {{ background: none; border: 1px solid #c44; color: #c44; border-radius: 6px; padding: 4px 12px; cursor: pointer; font-family: sans-serif; font-size: 14px; }}
            .topbar button:hover {{ background: #fceaea; }}
            blockquote {{ border-left: 4px solid #ddd; margin: 0 0 1em; padding: 0 0 0 16px; color: #555; }}
            code {{ background: #f0f0f0; padding: 2px 6px; border-radius: 4px; font-family: monospace; font-size: 0.85em; }}
            pre {{ background: #f0f0f0; padding: 12px; border-radius: 6px; overflow-x: auto; -webkit-overflow-scrolling: touch; }}
            pre code {{ background: none; padding: 0; font-size: 0.85em; }}
            .table-wrap {{ overflow-x: auto; -webkit-overflow-scrolling: touch; margin: 1em 0; }}
            table {{ border-collapse: collapse; font-size: 0.9em; }}
            th, td {{ border: 1px solid #ccc; padding: 6px 10px; }}
            .mod-badge {{ position: relative; }}
            .mod-badge:hover::after, .mod-badge.show-tip::after {{
                content: attr(data-reason);
                position: absolute; left: 0; top: 100%; margin-top: 4px;
                background: #222; color: #fff; padding: 6px 10px; border-radius: 6px;
                font-size: 12px; line-height: 1.4; white-space: normal;
                width: max-content; max-width: 280px; z-index: 10;
            }}
            @media (max-width: 600px) {{
                body {{ padding: 12px; font-size: 19px; }}
                .topbar {{ flex-direction: column; align-items: stretch; }}
                .topbar button {{ width: 100%; }}
            }}
            @media (prefers-color-scheme: dark) {{
                body {{ background: #16181d; color: #e6e6e6; }}
                a.back {{ color: #999; }}
                .topbar button {{ border-color: #e05a5a; color: #ff7a7a; }}
                .topbar button:hover {{ background: #2a1c1c; }}
                blockquote {{ border-left-color: #444; color: #aaa; }}
                code {{ background: #2a2e37; }}
                pre {{ background: #2a2e37; }}
                th, td {{ border-color: #3a3f4a; }}
            }}
        </style>
    </head>
    <body>
        <nav class="topbar">
            <a href="/" class="back">← Back to Collections</a>
            {delete_button_html}
        </nav>
        {verdict_html}
        <article id="story-article">{html_content}</article>
        <script>
            const storyId = {story_id_js};
            const article = document.getElementById('story-article');
            let lastHtml = article.innerHTML;

            function typesetMath() {{
                if (typeof renderMathInElement !== 'function') {{
                    setTimeout(typesetMath, 100);
                    return;
                }}
                renderMathInElement(article, {{
                    delimiters: [
                        {{left: '\\\\[', right: '\\\\]', display: true}},
                        {{left: '\\\\(', right: '\\\\)', display: false}},
                        {{left: '$$', right: '$$', display: true}},
                        {{left: '$', right: '$', display: false}}
                    ],
                    throwOnError: false
                }});
            }}
            typesetMath();

            async function poll() {{
                try {{
                    const r = await fetch('/story/{encoded_collection}/{encoded_story_path}/content');
                    if (!r.ok) return;
                    const data = await r.json();
                    const newHtml = data.html;
                    if (newHtml !== lastHtml) {{
                        if (newHtml.startsWith(lastHtml)) {{
                            const nearBottom = window.innerHeight + window.scrollY > document.body.scrollHeight - 150;
                            article.insertAdjacentHTML('beforeend', newHtml.slice(lastHtml.length));
                            if (nearBottom) window.scrollTo(0, document.body.scrollHeight);
                        }} else {{
                            article.innerHTML = newHtml;
                        }}
                        lastHtml = newHtml;
                        typesetMath();
                    }}
                }} catch (e) {{}}
                setTimeout(poll, 3000);
            }}
            poll();

            const deleteBtn = document.getElementById('delete-btn');
            if (deleteBtn) {{
                deleteBtn.addEventListener('click', async () => {{
                    if (!confirm(`Delete story "${{storyId}}" and all its images?`)) return;
                    try {{
                        const r = await fetch('/story/{encoded_collection}/{encoded_story_path}', {{ method: 'DELETE' }});
                        if (r.ok) {{
                            window.location.href = '/';
                        }} else {{
                            const d = await r.json();
                            alert('Delete failed: ' + (d.detail || r.status));
                        }}
                    }} catch (e) {{
                        alert('Delete failed: ' + e.message);
                    }}
                }});
            }}

            // Tap-to-toggle tooltip for the moderation reason (title= doesn't work on mobile touch).
            document.querySelectorAll('.mod-badge').forEach(el => {{
                el.addEventListener('click', e => {{
                    e.stopPropagation();
                    el.classList.toggle('show-tip');
                }});
            }});
            document.addEventListener('click', () => {{
                document.querySelectorAll('.mod-badge.show-tip').forEach(o => o.classList.remove('show-tip'));
            }});
        </script>
    </body>
    </html>
    """
</file>

<file path="server/api.py">
"""HTTP API endpoints for the chat web UI.

The request handlers here are the thin web layer over the chat engine in
``chat-webui.py``. All shared application state and helper functions live in the
entrypoint module and are injected here at startup via :func:`set_app_state`,
so nothing has to be duplicated.

The names in ``APP_STATE_NAMES`` are declared as module globals below and
replaced by the real objects when the entrypoint calls ``set_app_state``.
"""
import base64
import http.server
import json
import mimetypes
import os
import re
import time
import traceback
import uuid
from datetime import datetime
from urllib.parse import parse_qs, urlparse

import requests

from server.auth import (
    get_current_user,
    get_identity,
    identity_from_headers,
)
from server.config import (
    COMFYUI_OUTPUT,
    FORCE_GPU_LANE,
    IMG_PATH,
    SELF_CHAT_MODE,
    UPLOADS_DIR,
)

IMAGE_MIME = {
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".webp": "image/webp",
    ".gif": "image/gif",
    ".bmp": "image/bmp",
}


def resolve_image_file(image_id):
    """Resolve an ``/api/image/<id>`` identifier to a local image file path.

    Accepts ids shaped like the stored image URLs — ``uploads/<name>`` (user
    uploads) or ``output/<rel>`` (ComfyUI generated images) — plus bare
    filenames, which are looked up in the uploads dir first.
    """
    if not image_id:
        return None
    raw = urlparse(image_id).path
    raw = raw.lstrip("/")
    base = None
    if raw.startswith("uploads/"):
        base, rel = UPLOADS_DIR, raw[len("uploads/"):]
    elif raw.startswith("output/"):
        base, rel = COMFYUI_OUTPUT, raw[len("output/"):]
    else:
        base, rel = UPLOADS_DIR, os.path.basename(raw)
    root = os.path.realpath(base)
    fpath = os.path.realpath(os.path.join(root, rel))
    if fpath != root and not fpath.startswith(root + os.sep):
        return None
    if not os.path.isfile(fpath):
        return None
    return fpath

# ---------------------------------------------------------------------------
# Shared application state — injected by chat-webui.py via set_app_state().
# ---------------------------------------------------------------------------

ACTIVE_WINDOW_SECONDS = None
MAX_INPUT_TOKENS = None
MAX_QUEUE_SIZE = None
SHARES_FILE = None
_active_tokens = None
_agent_tokens = None
_agent_users = None
_user_last_seen = None
_user_last_seen_lock = None
_data_lock = None
_db_fetch = None
_effective_contexts = None
_effective_contexts_lock = None
_image_url_rel = None
_load_extra_prompts = None
_location_events = None
_queue_conds = None
_queue_locks = None
_task_queues = None
_tokens_lock = None
active_users = None
context_token_report = None
create_share = None
get_share = None
get_user_context_path = None
handle_theme_tool = None
list_shares = None
load_shares = None
model_status_snapshot = None
read_user_context = None
revoke_share = None
save_sessions = None
save_shares = None
sessions = None
sessions_meta = None
set_client_location = None
shares = None
task_create = None
task_delete = None
task_list = None
task_update = None
tasks = None
write_user_context = None

APP_STATE_NAMES = [
    "ACTIVE_WINDOW_SECONDS",
    "MAX_INPUT_TOKENS",
    "MAX_QUEUE_SIZE",
    "SHARES_FILE",
    "_active_tokens",
    "_agent_tokens",
    "_agent_users",
    "_user_last_seen",
    "_user_last_seen_lock",
    "_data_lock",
    "_db_fetch",
    "_effective_contexts",
    "_effective_contexts_lock",
    "_image_url_rel",
    "_load_extra_prompts",
    "_location_events",
    "_queue_conds",
    "_queue_locks",
    "_task_queues",
    "_tokens_lock",
    "active_users",
    "context_token_report",
    "create_share",
    "get_share",
    "get_user_context_path",
    "handle_theme_tool",
    "list_shares",
    "load_shares",
    "model_status_snapshot",
    "read_user_context",
    "revoke_share",
    "save_sessions",
    "save_shares",
    "sessions",
    "sessions_meta",
    "set_client_location",
    "shares",
    "task_create",
    "task_delete",
    "task_list",
    "task_update",
    "tasks",
    "write_user_context",
]


def set_app_state(state):
    """Inject the application state shared with the entrypoint module."""
    globals().update(state)


def read_index_html():
    p = os.path.join(
        os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
        "dist",
        "index.html",
    )
    try:
        with open(p) as f:
            return f.read()
    except:
        return "<html><body><h1>index.html missing</h1></body></html>"


class Handler(http.server.SimpleHTTPRequestHandler):
    def do_OPTIONS(self):
        self.send_response(200)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header(
            "Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"
        )
        self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Auth-Token")
        self.end_headers()

    def do_GET(self):
        if self.path == "/api/user-context":
            user = get_current_user(self.headers)
            if not user:
                self.send_json({"error": "Unauthorized"}, status=401)
                return
            context = read_user_context(user)
            self.send_json(
                {
                    "context": context,
                    "username": user,
                    "context_file": get_user_context_path(user),
                }
            )
        elif self.path == "/api/check-auth":
            identity = identity_from_headers(self.headers)
            if identity:
                self.send_json(
                    {
                        "authenticated": True,
                        "username": identity["username"],
                        "role": identity["role"] or "free",
                        "email": identity.get("email", ""),
                    }
                )
            else:
                self.send_json({"authenticated": False})
        elif self.path == "/api/active-users":
            now = time.time()
            with _user_last_seen_lock:
                raw = dict(_user_last_seen or {})
            with _tokens_lock:
                agent_users = set(_agent_users)
            active = sorted(
                u for u, last in raw.items()
                if u not in agent_users and now - last <= ACTIVE_WINDOW_SECONDS
            )
            self.send_json({"users": active})
        elif self.path == "/api/model-status":
            snap = model_status_snapshot()
            ms, tps, oh, gtemp, ram_evac = (
                snap["model"],
                snap["predicted_per_second"],
                snap["overheated"],
                snap["gpu_temp"],
                snap["ram_evacuating"],
            )
            try:
                user = get_current_user(self.headers)
                reminder_count = len(_db_fetch("SELECT id FROM tasks WHERE user_id=? AND reminder_at IS NOT NULL AND reminder_at <= ? AND reminded=0 AND status NOT IN ('completed','cancelled')", (user, datetime.now().isoformat()))) if user else 0
            except Exception:
                reminder_count = 0
            self.send_json(
                {
                    "model": ms,
                    "predicted_per_second": tps,
                    "overheated": oh,
                    "gpu_temp": gtemp,
                    "ram_evacuating": ram_evac,
                    "max_context": MAX_INPUT_TOKENS,
                    "reminder_count": reminder_count,
                }
            )
        elif self.path == "/api/shares":
            user = get_current_user(self.headers)
            if not user:
                self.send_json({"error": "Unauthorized"}, status=401)
                return
            self.send_json({"shares": list_shares(user)})
        elif self.path.startswith("/api/public/share/"):
            token = os.path.basename(self.path)
            rec = get_share(token)
            if not rec:
                self.send_error(404)
                return
            self.send_json(
                {
                    "message": rec.get("message", {}),
                    "created": rec.get("created"),
                    "shared_by": rec.get("owner", ""),
                }
            )
        elif self.path.startswith("/output/"):
            rel = urlparse(self.path).path
            rel = rel[len("/output/"):] if rel.startswith("/output/") else rel
            fpath = os.path.abspath(os.path.join(COMFYUI_OUTPUT, rel))
            if fpath.startswith(os.path.abspath(COMFYUI_OUTPUT)) and os.path.exists(
                fpath
            ):
                self.send_response(200)
                self.send_header("Content-Type", "image/png")
                self.end_headers()
                with open(fpath, "rb") as f:
                    self._safe_write(f.read())
                return
            self.send_error(404)
        elif self.path.startswith("/uploads/"):
            filename = os.path.basename(urlparse(self.path).path)
            fpath = os.path.abspath(os.path.join(UPLOADS_DIR, filename))
            if fpath.startswith(os.path.abspath(UPLOADS_DIR)) and os.path.exists(fpath):
                self.send_response(200)
                self.send_header("Content-Type", "application/octet-stream")
                self.send_header("Content-Disposition", "inline")
                self.end_headers()
                with open(fpath, "rb") as f:
                    self._safe_write(f.read())
                return
            self.send_error(404)
        elif self.path.startswith("/api/image/"):
            image_id = self.path[len("/api/image/"):]
            fpath = resolve_image_file(image_id)
            if fpath:
                ext = os.path.splitext(fpath)[1].lower()
                ctype = IMAGE_MIME.get(ext, "image/jpeg")
                self.send_response(200)
                self.send_header("Content-Type", ctype)
                self.send_header("Content-Disposition", "inline")
                self.send_header("Cache-Control", "public, max-age=31536000, immutable")
                self.end_headers()
                with open(fpath, "rb") as f:
                    self._safe_write(f.read())
                return
            self.send_error(404)
        elif self.path.startswith("/api/status/"):
            task_id = os.path.basename(self.path)
            with _data_lock:
                status = tasks.get(
                    task_id, {"status": "unknown", "message": "Not found"}
                )
            self.send_json(status)
        elif self.path == "/api/sessions":
            user = get_current_user(self.headers)
            if not user:
                self.send_json([], status=401)
                return
            with _data_lock:
                sorted_items = sorted(
                    sessions_meta.items(),
                    key=lambda x: x[1].get("updated", 0),
                    reverse=True,
                )
                result = [
                    {
                        "session_id": sid,
                        "name": meta.get("name", "Chat"),
                        "created": meta.get("created", 0),
                        "updated": meta.get("updated", 0),
                        **context_token_report(sid, sessions.get(sid, [])),
                    }
                    for sid, meta in sorted_items
                    if meta.get("user_id", "") == user
                ]
            self.send_json(result)
        elif self.path.startswith("/api/sessions/") and self.path.endswith("/messages"):
            user = get_current_user(self.headers)
            if not user:
                self.send_json({"error": "Unauthorized"}, status=401)
                return
            sid = self.path.split("/")[3]
            with _data_lock:
                meta = sessions_meta.get(sid)
                if not meta or meta.get("user_id", "") != user:
                    self.send_error(404)
                    return
                msgs = sessions.get(sid)
            if msgs is not None:
                self.send_json(
                    {
                        "messages": msgs,
                        **context_token_report(sid, msgs),
                    }
                )
            else:
                self.send_error(404)
        elif self.path == "/":
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Cache-Control", "no-cache")
            self.end_headers()
            self._safe_write(read_index_html().encode())
        else:
            DIST_DIR = os.path.join(
                os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "dist"
            )
            fpath = os.path.abspath(os.path.join(DIST_DIR, self.path.lstrip("/")))
            if fpath.startswith(os.path.abspath(DIST_DIR)) and os.path.isfile(fpath):
                ctype, _ = mimetypes.guess_type(fpath)
                self.send_response(200)
                self.send_header("Content-Type", ctype or "application/octet-stream")
                self.send_header("Cache-Control", "public, max-age=31536000, immutable")
                self.end_headers()
                with open(fpath, "rb") as f:
                    self._safe_write(f.read())
            elif self.path.startswith("/api/") or "." in os.path.basename(self.path):
                if self.path == "/api/tasks":
                    user = get_current_user(self.headers)
                    if not user:
                        self.send_json({"error": "Unauthorized"}, status=401)
                        return
                    user_tasks = task_list(user)
                    self.send_json({"tasks": user_tasks})
                elif self.path.startswith("/api/themes"):
                    identity = get_identity(self.headers)
                    if not identity:
                        self.send_json({"error": "Unauthorized"}, status=401)
                        return
                    qs = parse_qs(urlparse(self.path).query)
                    scope = qs.get("scope", [""])[0] or None
                    is_global = qs.get("global", ["0"])[0] in ("1", "true", "True", "yes")
                    if is_global and identity["role"] != "admin":
                        self.send_json({"error": "Admin role required"}, status=403)
                        return
                    result = handle_theme_tool(
                        identity["username"],
                        {
                            "operation": "list",
                            "scope": scope,
                            "global": is_global,
                            "status": qs.get("status", [""])[0] or None,
                            "limit": qs.get("limit", ["50"])[0] or 50,
                        },
                    )
                    self.send_json(json.loads(result))
                else:
                    self.send_error(404)
            else:
                self.send_response(200)
                self.send_header("Content-Type", "text/html; charset=utf-8")
                self.send_header("Cache-Control", "no-cache")
                self.end_headers()
                self._safe_write(read_index_html().encode())

    def do_DELETE(self):
        if self.path.startswith("/api/shares/"):
            user = get_current_user(self.headers)
            if not user:
                self.send_json({"error": "Unauthorized"}, status=401)
                return
            token = self.path.split("/")[3]
            if revoke_share(token, user):
                self.send_json({"status": "revoked"})
            else:
                self.send_json({"error": "Share not found or not yours"}, status=404)
        elif self.path.startswith("/api/sessions/"):
            user = get_current_user(self.headers)
            if not user:
                self.send_json({"error": "Unauthorized"}, status=401)
                return
            sid = self.path.split("/")[3]
            with _data_lock:
                meta = sessions_meta.get(sid)
                if not meta or meta.get("user_id", "") != user:
                    self.send_error(404)
                    return
                msgs = list(sessions.get(sid, []))
            # Cancel all queued/in-flight tasks for this session so they stop processing
            for mode in ("gpu", "cpu"):
                with _queue_locks[mode]:
                    q = _task_queues[mode]
                    q[:] = [item for item in q if item.get("session_id") != sid]
            with _data_lock:
                for tid, t in tasks.items():
                    if t.get("session_id") == sid and t.get("status") not in ("done", "error"):
                        tasks[tid] = {
                            "status": "cancelled",
                            "error": "Session was deleted",
                            "session_id": sid,
                        }
            for msg in msgs:
                if msg.get("role") == "assistant":
                    url = msg.get("_image_url", "") or ""
                    if url:
                        fname = os.path.join(IMG_PATH, _image_url_rel(url))
                        fpath = fname
                        if os.path.exists(fpath):
                            print(f"[delete] Removed output image: {fpath}")
                            os.remove(fpath)
                raw = msg.get("content", "")
                texts = []
                if isinstance(raw, str):
                    texts.append(raw)
                elif isinstance(raw, list):
                    for part in raw:
                        if isinstance(part, dict) and part.get("type") == "text":
                            texts.append(part.get("text", ""))
                for text in texts:
                    for part in text.split("[FILE:"):
                        idx = part.find("/uploads/")
                        if idx != -1:
                            url_part = part[idx:].split("]")[0]
                            fname = os.path.basename(url_part)
                            fpath = os.path.join(UPLOADS_DIR, fname)
                            if os.path.exists(fpath):
                                print(f"[delete] Removed uploaded file: {fpath}")
                                os.remove(fpath)
            # Remove image uploads stored as image_url content parts (the
            # /uploads/ URLs written by _save_upload_image).
            for msg in msgs:
                raw = msg.get("content", "")
                if not isinstance(raw, list):
                    continue
                for part in raw:
                    if not isinstance(part, dict) or part.get("type") != "image_url":
                        continue
                    url = part.get("image_url", {}).get("url", "")
                    if not url.startswith("/uploads/"):
                        continue
                    fname = os.path.basename(url.split("?", 1)[0])
                    fpath = os.path.join(UPLOADS_DIR, fname)
                    if os.path.exists(fpath):
                        print(f"[delete] Removed uploaded image: {fpath}")
                        os.remove(fpath)

            with _data_lock:
                exists = sid in sessions
                if exists:
                    sessions.pop(sid, None)
                    sessions_meta.pop(sid, None)
            if exists:
                with _effective_contexts_lock:
                    _effective_contexts.pop(sid, None)
            if exists:
                save_sessions()
                self.send_json({"status": "deleted"})
            else:
                self.send_error(404)
        elif self.path.startswith("/api/tasks/"):
            user = get_current_user(self.headers)
            if not user:
                self.send_json({"error": "Unauthorized"}, status=401)
                return
            tid = self.path.split("/")[3]
            if task_delete(tid, user):
                self.send_json({"status": "deleted"})
            else:
                self.send_error(404)
        else:
            self.send_error(404)

    def do_PUT(self):
        if self.path.startswith("/api/sessions/"):
            user = get_current_user(self.headers)
            if not user:
                self.send_json({"error": "Unauthorized"}, status=401)
                return
            sid = self.path.split("/")[3]
            length = int(self.headers.get("Content-Length", 0))
            body = json.loads(self.rfile.read(length))
            with _data_lock:
                meta = sessions_meta.get(sid)
                if meta and meta.get("user_id", "") == user:
                    meta["name"] = body.get("name", meta["name"])
                    meta["updated"] = time.time()
            if meta:
                save_sessions()
                self.send_json({"status": "updated"})
            else:
                self.send_error(404)
        elif self.path.startswith("/api/tasks/"):
            user = get_current_user(self.headers)
            if not user:
                self.send_json({"error": "Unauthorized"}, status=401)
                return
            tid = self.path.split("/")[3]
            length = int(self.headers.get("Content-Length", 0))
            body = json.loads(self.rfile.read(length))
            t = task_update(tid, user, **{k: v for k, v in body.items() if k in ("title","description","status","priority","due_date","reminder_at")})
            if t:
                self.send_json({"task": t})
            else:
                self.send_error(404)
        else:
            self.send_error(404)

    def do_POST(self):
        if self.path == "/api/shares":
            user = get_current_user(self.headers)
            if not user:
                self.send_json({"error": "Unauthorized"}, status=401)
                return
            length = int(self.headers.get("Content-Length", 0))
            body = json.loads(self.rfile.read(length)) if length else {}
            try:
                token, url = create_share(
                    user, body.get("session_id", ""), body.get("msg_index")
                )
            except ValueError as e:
                self.send_json({"error": str(e)}, status=400)
                return
            self.send_json({"token": token, "url": url})
        elif self.path == "/api/register-agent":
            length = int(self.headers.get("Content-Length", 0))
            body = json.loads(self.rfile.read(length)) if length else {}
            tokens = body.get("tokens", []) or []
            usernames = body.get("usernames", []) or []
            print("Agent registered")
            with _tokens_lock:
                for t in tokens:
                    _agent_tokens.add(t)
                for u in usernames:
                    _agent_users.add(u)
            self.send_json({"ok": True})
        elif self.path == "/api/leaving":
            # Fired via navigator.sendBeacon on pagehide. With SSO the browser
            # sends no custom header, so the username travels in the body;
            # mark this user's heartbeat stale immediately instead of waiting
            # out ACTIVE_WINDOW_SECONDS (which stays as a crash-fallback).
            username = ""
            length = int(self.headers.get("Content-Length", 0))
            if length:
                try:
                    body = json.loads(self.rfile.read(length))
                    username = body.get("username", "")
                except Exception:
                    username = ""
            if not username:
                identity = identity_from_headers(self.headers)
                username = (identity or {}).get("username", "")
            with _tokens_lock:
                if username and _user_last_seen:
                    _user_last_seen.pop(username, None)
            self.send_json({"ok": True})
        elif self.path == "/api/logout":
            # Logout is handled at the nginx/Authentik layer (SSO session
            # cookie). Nothing server-side to invalidate here.
            self.send_json({"ok": True})
        elif self.path == "/api/user-context":
            user = get_current_user(self.headers)
            if not user:
                self.send_json({"error": "Unauthorized"}, status=401)
                return
            length = int(self.headers.get("Content-Length", 0))
            body = json.loads(self.rfile.read(length))
            action = body.get("action", "read")
            if action == "write":
                content = body.get("context", "")
                write_user_context(user, content)
                self.send_json({"status": "ok", "username": user})
            elif action == "overwrite":
                identity = identity_from_headers(self.headers)
                if not identity or identity["role"] != "admin":
                    self.send_json({"error": "Admin role required to overwrite context"}, status=403)
                    return
                content = body.get("context", "")
                path = get_user_context_path(user)
                if path:
                    os.makedirs(os.path.dirname(path), exist_ok=True)
                    with open(path, "w") as f:
                        f.write(content)
                self.send_json({"status": "ok", "username": user})
            else:
                context = read_user_context(user)
                self.send_json(
                    {
                        "context": context,
                        "username": user,
                        "context_file": get_user_context_path(user),
                    }
                )
        elif self.path == "/api/chat":
            user = get_current_user(self.headers)
            if not user:
                self.send_json({"error": "Unauthorized"}, status=401)
                return
            length = int(self.headers.get("Content-Length", 0))
            body = json.loads(self.rfile.read(length))
            task_id = str(uuid.uuid4())
            sid = body.get("session_id", "default")
            with _data_lock:
                meta = sessions_meta.get(sid)
                if not meta or meta.get("user_id", "") != user:
                    self.send_json({"error": "Session not found"}, status=404)
                    return

            entry = {
                "task_id": task_id,
                "session_id": sid,
                "message": body.get("message", ""),
                "image": body.get("image"),
                "audio": body.get("audio"),
                "user": user,
                "client_timestamp": body.get("client_timestamp"),
                "research": bool(body.get("research")),
                "cpu": bool(body.get("cpu")) and bool(body.get("research")),
                "no_tools": bool(body.get("no_tools")),
            }
            # Route to the GPU lane (interactive UI users) or the lane chosen
            # by SELF_CHAT_MODE — cpu (self-chat agents on the RAM-backed CPU
            # server) or gpu (agents sharing the interactive GPU server) — so
            # the two never wait behind each other. Agent users may override
            # the lane per request (self-chat.py --gpu sends mode="gpu"); the
            # override is ignored for interactive users, who always use GPU.
            # Interactive users may also opt into the CPU lane explicitly for a
            # research task via the UI's "CPU" toggle (gated on Research mode,
            # and honored server-side only when research is set).
            cpu_flagged = entry["cpu"]
            mode = body.get("mode")
            if mode not in ("gpu", "cpu") or user not in _agent_users:
                mode = SELF_CHAT_MODE if user in _agent_users else "gpu"
            if cpu_flagged:
                mode = "cpu"
            if FORCE_GPU_LANE and not cpu_flagged:
                # Test-time override: never admit anything to the CPU lane.
                mode = "gpu"
            entry["mode"] = mode
            with _queue_locks[mode]:
                if len(_task_queues[mode]) >= MAX_QUEUE_SIZE:
                    self.send_json({"error": "Server busy"}, status=503)
                    return
                _task_queues[mode].append(entry)
                _queue_conds[mode].notify()
            with _data_lock:
                tasks[task_id] = {
                    "status": "queued",
                    "message": "Waiting in line...",
                    "session_id": sid,
                    "mode": mode,
                    "research": bool(body.get("research")),
                    "cpu": cpu_flagged,
                    "no_tools": bool(body.get("no_tools")),
                }
            self.send_json({"task_id": task_id})
        elif self.path == "/api/extract-file":
            length = int(self.headers.get("Content-Length", 0))
            body = json.loads(self.rfile.read(length))
            name = body.get("name", "")
            data_b64 = body.get("data", "")
            ext = os.path.splitext(name)[1].lower()
            safe_name = str(uuid.uuid4()) + ext
            filepath = os.path.join(UPLOADS_DIR, safe_name)
            raw = base64.b64decode(data_b64)
            with open(filepath, "wb") as f:
                f.write(raw)
            file_url = f"/uploads/{safe_name}"
            self.send_json({"url": file_url, "name": name})
        elif self.path == "/api/upload-image":
            user = get_current_user(self.headers)
            if not user:
                self.send_json({"error": "Unauthorized"}, status=401)
                return
            length = int(self.headers.get("Content-Length", 0))
            body = json.loads(self.rfile.read(length))
            data_b64 = body.get("data", "")
            ext = (body.get("ext") or "jpg").lstrip(".").lower()
            if ext not in ("png", "jpg", "jpeg", "webp", "gif", "bmp"):
                ext = "jpg"
            try:
                raw = base64.b64decode(data_b64, validate=False)
            except Exception:
                self.send_json({"error": "Invalid image data"}, status=400)
                return
            safe_name = str(uuid.uuid4()) + "." + ext
            os.makedirs(UPLOADS_DIR, exist_ok=True)
            filepath = os.path.join(UPLOADS_DIR, safe_name)
            with open(filepath, "wb") as f:
                f.write(raw)
            self.send_json({"url": f"/uploads/{safe_name}"})
        elif self.path == "/api/tts":
            user = get_current_user(self.headers)
            if not user:
                self.send_json({"error": "Unauthorized"}, status=401)
                return
            length = int(self.headers.get("Content-Length", 0))
            body = json.loads(self.rfile.read(length))
            raw_text = body.get("text", "")
            if not raw_text:
                self.send_json({"error": "No text provided"}, status=400)
                return
            try:
                import re
                text = raw_text
                voice = body.get("voice", "")

                # Detect language tag from LLM prefix: [bn], [hi], [te], [kn], [en]
                m = re.match(r"^\s*\[(bn|hi|te|kn|en)\]\s*", text)
                if m:
                    tag = m.group(1)
                    text = text[m.end():]
                elif not voice:
                    bn = len(re.findall(r"[\u0980-\u09FF]", text))
                    hi = len(re.findall(r"[\u0900-\u097F]", text))
                    te = len(re.findall(r"[\u0C00-\u0C7F]", text))
                    kn = len(re.findall(r"[\u0C80-\u0CFF]", text))
                    scores = {"bn": bn, "hi": hi, "te": te, "kn": kn}
                    tag = max(scores, key=scores.get)
                    if scores[tag] == 0:
                        tag = "en"
                else:
                    tag = "en"

                # Determine TTS backend
                PIPER_VOICES = {
                    "bn": "/home/palash/.piper_voices/bn_BD-google-medium.onnx",
                    "hi": "/home/palash/.piper_voices/hi_IN-priyamvada-medium.onnx",
                    "te": "/home/palash/.piper_voices/te_IN-padmavathi-medium.onnx",
                    "en": "/home/palash/.piper_voices/en_US-amy-medium.onnx",
                }
                EDGE_VOICES = {
                    "bn": "bn-IN-TanishaaNeural",
                    "hi": "hi-IN-SwaraNeural",
                    "te": "te-IN-ShrutiNeural",
                    "kn": "kn-IN-GaganNeural",
                    "en": "en-US-AriaNeural",
                }

                if tag in PIPER_VOICES:
                    import piper, io, struct, wave
                    onnx_path = PIPER_VOICES[tag]
                    cfg_path = onnx_path + ".json"
                    if not hasattr(self, "_piper_voices"):
                        self._piper_voices = {}
                    if tag not in self._piper_voices:
                        print(f"[tts] Loading Piper voice '{tag}' ...")
                        self._piper_voices[tag] = piper.PiperVoice.load(
                            onnx_path, config_path=cfg_path
                        )
                    pv = self._piper_voices[tag]
                    print(f"[tts] Piper {tag}: synthesizing {len(text)} chars")
                    wav_io = io.BytesIO()
                    with wave.open(wav_io, "wb") as wf:
                        wf.setnchannels(1)
                        wf.setsampwidth(2)
                        wf.setframerate(22050)
                        for chunk in pv.synthesize(text):
                            int16 = (chunk.audio_float_array * 32767).clip(-32768, 32767).astype("<i2")
                            wf.writeframes(int16.tobytes())
                    audio_b64 = base64.b64encode(wav_io.getvalue()).decode()
                    self.send_json({"audio": audio_b64, "type": "audio/wav"})
                else:
                    import asyncio, edge_tts
                    edge_voice = voice or EDGE_VOICES.get(tag, "en-US-AriaNeural")
                    print(f"[tts] edge-tts {tag} ({edge_voice}): {len(text)} chars")
                    communicate = edge_tts.Communicate(text, edge_voice)
                    mp3_data = bytearray()
                    async def _gen():
                        async for chunk in communicate.stream():
                            if chunk["type"] == "audio":
                                mp3_data.extend(chunk["data"])
                    asyncio.run(_gen())
                    audio_b64 = base64.b64encode(bytes(mp3_data)).decode()
                    self.send_json({"audio": audio_b64, "type": "audio/mpeg"})
            except Exception as e:
                print(f"[tts] Error: {e}")
                traceback.print_exc()
                self.send_json({"error": str(e)}, status=500)
        elif self.path == "/api/sessions":
            user = get_current_user(self.headers)
            if not user:
                self.send_json({"error": "Unauthorized"}, status=401)
                return
            length = int(self.headers.get("Content-Length", 0))
            extra = {}
            context_tokens = {}
            system_prompt = ""
            if length:
                try:
                    ext_body = json.loads(self.rfile.read(length))
                    extra = _load_extra_prompts(ext_body.get("system_prompts") or [])
                    context_tokens = ext_body.get("context_tokens") or {}
                    system_prompt = ext_body.get("system_prompt") or ""
                except Exception:
                    extra = []
            sid = str(uuid.uuid4())
            now = time.time()
            with _data_lock:
                sessions[sid] = []
                sessions_meta[sid] = {
                    "name": "New Chat",
                    "created": now,
                    "updated": now,
                    "user_id": user,
                    "system_prompts": extra,
                    "context_tokens": context_tokens,
                    "system_prompt": system_prompt,
                }
            save_sessions()
            self.send_json({"session_id": sid})
        elif self.path == "/api/location":
            length = int(self.headers.get("Content-Length", 0))
            body = json.loads(self.rfile.read(length))
            task_id = body.get("task_id")
            if body.get("denied"):
                set_client_location("")
                ev = _location_events.get(task_id) if task_id else None
                if ev:
                    ev.set()
                self.send_json({"ok": True})
                return
            lat = body.get("latitude")
            lng = body.get("longitude")
            if lat is not None and lng is not None:
                try:
                    geo = requests.get(
                        "https://nominatim.openstreetmap.org/reverse",
                        params={"format": "json", "lat": lat, "lon": lng},
                        headers={"User-Agent": "LocalAI/1.0"},
                        timeout=5,
                    ).json()
                    display = geo.get("display_name", "")
                    set_client_location(display)
                except Exception:
                    set_client_location(f"{lat:.4f}, {lng:.4f}")
            ev = _location_events.get(task_id) if task_id else None
            if ev:
                ev.set()
            self.send_json({"ok": True})
        elif self.path == "/api/tasks":
            user = get_current_user(self.headers)
            if not user:
                self.send_json({"error": "Unauthorized"}, status=401)
                return
            length = int(self.headers.get("Content-Length", 0))
            body = json.loads(self.rfile.read(length))
            t = task_create(user, body.get("title", "Untitled"), body.get("description", ""), body.get("priority", "medium"), body.get("due_date"), body.get("session_id"), body.get("reminder_at"))
            self.send_json({"task": t})
        elif self.path == "/api/themes":
            identity = get_identity(self.headers)
            if not identity:
                self.send_json({"error": "Unauthorized"}, status=401)
                return
            length = int(self.headers.get("Content-Length", 0))
            body = json.loads(self.rfile.read(length))
            if body.get("global") and identity["role"] != "admin":
                self.send_json({"error": "Admin role required"}, status=403)
                return
            result = handle_theme_tool(identity["username"], body)
            self.send_json(json.loads(result))
        else:
            self.send_error(404)

    def _safe_write(self, data):
        try:
            self.wfile.write(data)
        except (BrokenPipeError, ConnectionResetError):
            pass

    def send_json(self, data, status=200):
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self._safe_write(json.dumps(data).encode())

    def log_message(self, format, *args):
        pass

async def handle_chat(user_message):
    # Run both LLM calls in parallel to prevent waiting
    user_response, bot_response = await asyncio.gather(
        llm_call_user(user_message),
        llm_call_bot(user_message)
    )
    return user_response, bot_response
</file>

<file path="chat-webui.py">
#!/usr/bin/env python3
"""Chat Web UI — script entrypoint.

The chat engine implementation now lives in the :mod:`server.features` package.
This file remains the single script entrypoint and the owner of every shared
value: it re-exports the feature modules' functions and state, registers itself
with the ``M`` proxy (see :mod:`server.features.state`), and wires up the
background threads.

Feature code resolves shared state, config values and cross-cutting helpers at
call time through the ``M`` proxy, so monkeypatching ``chat-webui.<name>`` —
which the test-suite relies on — keeps working across module boundaries.
"""
import http.server, json, os, re, glob, uuid, base64, requests, subprocess, time, random, threading, sys, io, tempfile, queue as _queue_mod  # noqa: F401

sys.stdout.reconfigure(line_buffering=True)  # noqa
from datetime import datetime  # noqa: F401
from urllib.parse import urlparse, parse_qs  # noqa: F401

from server.read_file import read_file_text

from server.config import (  # noqa: F401
    AUDIO_TOKEN_COST,
    COMFYUI_DIR,
    COMFYUI_INPUT,
    COMFYUI_OUTPUT,
    COMFYUI_URL,
    DDNS_CHECK_INTERVAL,
    DDNS_DOMAIN,
    DDNS_SUBDOMAIN,
    FILES_DIR,
    FORCE_GPU_LANE,
    GODADDY_API_KEY,
    GODADDY_API_SECRET,
    HEARTBEAT_URL,
    HOST,
    IMG_PATH,
    IMAGE_MODELS,
    IMAGE_TOKEN_COST,
    LLAMA_BASE,
    LLAMA_BASE_CPU,
    LLAMA_GEMMA_NGL,
    LLAMA_QWEN_NGL,
    LLAMA_SERVER_ARGS,
    LLAMA_SERVER_ARGS_CPU,
    LLAMA_SERVER_PATH,
    LLAMA_SLOT_SAVE_DIR,
    LLAMA_URL,
    LLAMA_URL_CPU,
    MODEL_ID,
    MODEL_ID_CPU,
    PER_MESSAGE_OVERHEAD,
    PORT,
    PROMPT_PATH,
    REASONING_BUDGET,
    SEARXNG_URL,
    SELF_CHAT_MODE,
    SESSIONS_DIR,
    SESSIONS_FILE,
    SHARE_BASE_URL,
    SHARES_FILE,
    TASKS_DB,
    THEMES_DB,
    TOOL_FREE_AGENTS,
    TOOLS,
    TOOLS_TOKEN_COST,
    UPLOADS_DIR,
    VENV_PYTHON,
    VERIFY_FETCH_CHARS,
    VERIFY_MAX_CITES_PER_URL,
    VERIFY_RETRIES,
    build_sys_content,
)

from server.api import APP_STATE_NAMES, Handler, set_app_state

import sys

sys.path.insert(0, COMFYUI_DIR)

# ---------------------------------------------------------------------------
# Feature modules — the chat engine implementation.
# ---------------------------------------------------------------------------

from server.features.state import (  # noqa: E402
    ACTIVE_WINDOW_SECONDS,
    AUTO_COMPACT_THRESHOLD,
    MAX_INPUT_TOKENS,
    MAX_QUEUE_SIZE,
    MAX_TOOL_ROUNDS,
    RAM_EVAC_THRESHOLD,
    RAM_RESUME_THRESHOLD,
    TEMP_THRESHOLD_OFF,
    TEMP_THRESHOLD_ON,
    _active_tokens,
    _agent_tokens,
    _agent_users,
    _client_location,
    _cpu_last_llm_use,
    _cpu_model_status,
    _current_task_ids,
    _data_lock,
    _effective_contexts,
    _effective_contexts_lock,
    _event_queue,
    _gpu_temp,
    _image_queue,
    _last_llm_use,
    _last_tps,
    _llm_pools,
    _location_events,
    _model_transition_lock,
    _overheated,
    _queue_conds,
    _queue_locks,
    _ram_evacuating,
    _slot_checkpoints,
    _slot_kv_dirty,
    _task_queues,
    _tokens_lock,
    _tool_pools,
    _user_last_seen,
    _user_last_seen_lock,
    _users_cache,
    _users_cache_time,
    model_status,
    register_entrypoint,
    sessions,
    sessions_meta,
    shares,
    tasks,
)

from server.features.tasks_db import (  # noqa: E402
    _db_fetch,
    _db_fetch_one,
    _db_run,
    _init_tasks_db,
    handle_task_tool,
    task_complete,
    task_create,
    task_delete,
    task_get,
    task_list,
    task_update,
)

from server.features.themes_db import (  # noqa: E402
    _init_themes_db,
    handle_theme_tool,
)

from server.features.users import (  # noqa: E402
    _safe_username,
    active_users,
    get_current_identity,
    get_current_user,
    get_user_context_path,
    read_user_context,
    write_user_context,
)

from server.features.sessions import (  # noqa: E402
    _load_extra_prompts,
    _prepare_session,
    _session_file,
    _session_meta_from,
    load_sessions,
    save_sessions,
)

from server.features.shares import (  # noqa: E402
    create_share,
    get_share,
    list_shares,
    load_shares,
    revoke_share,
    save_shares,
)

from server.features.context import (  # noqa: E402
    _image_to_data_url,
    _latest_read_image_url,
    _reference_historical_images,
    _summarize_with_llm,
    _text_tokens,
    compact_messages_copy,
    context_token_report,
    effective_token_estimate,
    estimate_tokens,
    prepare_context_for_llm,
    resolve_image_path,
    strip_html,
    trim_messages_for_context,
)

from server.features.llm import (  # noqa: E402
    _consult_worker,
    _inject_read_image,
    _llm_worker,
    _start_llm_round,
    active_model_id,
    consult_expert_model,
    is_llama_alive,
    load_llama_model,
    mark_slot_kv_dirty,
    restore_slot_checkpoint,
    save_slot_checkpoint,
    server_base,
    server_last_use,
    server_model_id,
    server_status,
    server_url,
    task_mode,
    unload_llama_model,
)
from server.features.tools import (  # noqa: E402
    _dispatch_tool,
    _tool_worker,
    fetch_page,
    web_search,
)

from server.features.images import (  # noqa: E402
    _enqueue_image_job,
    _image_url_rel,
    _image_worker,
    _input_dir,
    _output_dir,
    _output_rel,
    edit_image,
    free_comfyui_vram,
    generate_image,
)

from server.features.monitoring import (  # noqa: E402
    _cpu_lane_needed,
    _ensure_llama_server_for_task,
    _evacuate_ram,
    _idle_unload_loop,
    _reminder_loop,
    _thermal_monitor,
    _connection_manager,
    ensure_comfyui_running,
    ensure_llama_server,
    get_gpu_temp,
    get_ram_usage,
    kill_comfyui,
    kill_llama_server,
    model_status_snapshot,
    restart_llama_server,
    restart_servers,
)

from server.features.orchestration import (  # noqa: E402
    _delete_task_image,
    _event_loop,
    _event_post,
    _finalize_task,
    _human_priority_active,
    _queue_worker,
    _set_task_error,
    _task_max_rounds,
    _task_user,
    location_str,
    set_client_location,
    set_status,
)

from server.features.critic import (  # noqa: E402
    extract_citations,
    run_verification,
    run_verification_worker,
)

# Point the M proxy at this module: from here on, feature modules resolve all
# shared state, config values and cross-cutting helpers through it.
register_entrypoint(sys.modules[__name__])

_init_tasks_db()
_init_themes_db()

SYS_CONTENT = build_sys_content()

print("Prompt:\n", "*" * 80, "\n", SYS_CONTENT, "\n", "*" * 80)

set_app_state({name: globals()[name] for name in APP_STATE_NAMES})


if __name__ == "__main__":
    os.makedirs(UPLOADS_DIR, exist_ok=True)
    load_sessions()
    load_shares()
    try:
        r = requests.get(f"{LLAMA_BASE}/health", timeout=3)
        if r.status_code != 200:
            raise Exception("health check failed")
        print("[startup] GPU llama-server is running")
    except Exception:
        print("[startup] GPU llama-server not reachable — starting...")
        restart_servers()
    try:
        r = requests.get(SEARXNG_URL, timeout=3)
        if r.status_code in (200, 301, 302):
            print("[startup] SearXNG is running")
        else:
            raise Exception(f"status {r.status_code}")
    except Exception as e:
        print(f"[startup] ERROR: SearXNG is not reachable at {SEARXNG_URL} ({e}). Web search will not work. Exiting.")
        sys.exit(1)
    threading.Thread(target=_event_loop, daemon=True).start()
    # One queue worker per lane: GPU (interactive UI users) and CPU (self-chat
    # agents) now run fully independently, so an agent task can never make a
    # UI user wait behind it.
    threading.Thread(target=_queue_worker, args=("gpu",), daemon=True).start()
    threading.Thread(target=_queue_worker, args=("cpu",), daemon=True).start()
    threading.Thread(target=_image_worker, daemon=True).start()
    threading.Thread(target=_idle_unload_loop, daemon=True).start()
    threading.Thread(target=_thermal_monitor, daemon=True).start()
    threading.Thread(target=_reminder_loop, daemon=True).start()
    threading.Thread(target=_connection_manager, daemon=True).start()
    print(f"Chat UI running on http://localhost:{PORT}")
    s = http.server.HTTPServer((HOST, PORT), Handler)
    try:
        s.serve_forever()
    except KeyboardInterrupt:
        s.shutdown()
</file>

<file path="server/config.py">
#!/usr/bin/env python3
import json
import os

from server.dotenv import load_dotenv

load_dotenv()

LLAMA_BASE = "http://localhost:8081"
LLAMA_URL = f"{LLAMA_BASE}/v1/chat/completions"

# The CPU-backed llama-server that serves automated self-chat agents
# (editor/moderator/registered agents). It runs CONCURRENTLY with the GPU
# llama-server on its own port so background agent runs never compete with
# interactive UI users for VRAM.
LLAMA_BASE_CPU = "http://localhost:8079"
LLAMA_URL_CPU = f"{LLAMA_BASE_CPU}/v1/chat/completions"

VENV_PYTHON = os.path.expanduser("~/local-ai/ComfyUI/venv/bin/python")
COMFYUI_DIR = os.path.expanduser("~/local-ai/ComfyUI")
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://127.0.0.1:8080/")
COMFYUI_URL = "http://localhost:8188"
HOST = os.environ.get("CHAT_HOST", "0.0.0.0")
PORT = 3001

# GoDaddy Dynamic DNS — updates the AAAA record for DDNS_DOMAIN/DDNS_SUBDOMAIN
# with this machine's stable global IPv6 on a timer (see the ConnectionManager
# thread in server/features/monitoring.py). Secret credentials come from the
# environment (e.g. an EnvironmentFile / /etc/environment); leave the keys
# empty to disable the updater entirely.
GODADDY_API_KEY = os.environ.get("GODADDY_API_KEY", "")
GODADDY_API_SECRET = os.environ.get("GODADDY_API_SECRET", "")
DDNS_DOMAIN = os.environ.get("DDNS_DOMAIN", "palashkantikundu.in")
DDNS_SUBDOMAIN = os.environ.get("DDNS_SUBDOMAIN", "home")
DDNS_CHECK_INTERVAL = int(os.environ.get("DDNS_CHECK_INTERVAL", "300"))

# GCP heartbeat — the ConnectionManager thread POSTs this machine's addresses
# to the receiver running on the GCP VM (scripts/gcp_heartbeat_server.py)
# over the WireGuard tunnel every 10s.
HEARTBEAT_URL = os.environ.get("HEARTBEAT_URL", "http://10.66.66.1:9863/heartbeat")

# External origin used to build public share links. Set this to a portless URL
# (e.g. http://192.168.1.10 or https://chat.example.com) when the server is also
# reachable on port 80/443, because WhatsApp and several other messengers stop
# auto-linking a URL at the ":" of a port: a share link like
# "http://192.168.1.10:3001/s/<token>" becomes a dead short URL that ends at the
# colon. Leave empty to keep building links from the browser's own origin.
SHARE_BASE_URL = os.environ.get("SHARE_BASE_URL", "").strip().rstrip("/")
REASONING_BUDGET = 4096
CPU_PARALLEL_SLOTS = 4  # Set to desired number of concurrent CPU agent slots

# ─────────────────────────────────────────────────────────────────────────────
# Unified RBAC / SSO — Authentik is the SINGLE identity provider.
#
# There is no users.json anymore. Browser users authenticate through nginx's
# auth_request → Authentik proxy outpost (the X-Authentik-* claim headers are
# trusted downstream); self-chat agents authenticate via an OAuth2 password
# grant and send the resulting JWT as "Authorization: Bearer <token>", which
# the backends verify against Authentik's JWKS (see server/auth.py).
#
# AUTHENTIK_BASE_URL must NOT have a trailing slash.
# ─────────────────────────────────────────────────────────────────────────────
AUTHENTIK_BASE_URL = os.environ.get("AUTHENTIK_BASE_URL", "https://home.palashkantikundu.in/sso").rstrip("/")
# Interactive browser SSO application (humans via nginx auth_request).
AUTH_CLIENT_ID = os.environ.get("AUTH_CLIENT_ID", "local-ai")
AUTH_CLIENT_SECRET = os.environ.get("AUTH_CLIENT_SECRET", "")
AUTH_SCOPE = os.environ.get("AUTH_SCOPE", "openid profile email groups")
# Machine-agent OIDC client (self-chat). Separate application in Authentik so
# agent credentials never mix with the human SSO client. The client_id must
# equal the Authentik application slug — the token/jwks endpoints are routed
# by slug, not by client_id.
AUTH_AGENTS_CLIENT_ID = os.environ.get("AUTH_AGENTS_CLIENT_ID", "")
AUTH_AGENTS_CLIENT_SECRET = os.environ.get("AUTH_AGENTS_CLIENT_SECRET", "")
AUTH_AGENTS_APP_SLUG = os.environ.get("AUTH_AGENTS_APP_SLUG", AUTH_AGENTS_CLIENT_ID)
# Token endpoint used by the machine-agent password grant. Authentik only
# exposes a generic token endpoint (the client_id in the body selects the
# provider); the per-slug routes exist for authorize/jwks but not token.
AUTH_AGENTS_TOKEN_URL = os.environ.get(
    "AUTH_AGENTS_TOKEN_URL",
    f"{AUTHENTIK_BASE_URL}/application/o/token/",
)
# JWKS endpoint used to verify agent access tokens. Authentik exposes it at
# /application/o/<application-slug>/jwks/.
AUTH_AGENTS_JWKS_URL = os.environ.get(
    "AUTH_AGENTS_JWKS_URL",
    f"{AUTHENTIK_BASE_URL}/application/o/{AUTH_AGENTS_APP_SLUG}/jwks/",
)
AUTH_AGENTS_ISSUER = os.environ.get(
    "AUTH_AGENTS_ISSUER",
    f"{AUTHENTIK_BASE_URL}/application/o/{AUTH_AGENTS_APP_SLUG}/",
)
# Map Authentik group names → the role scale used by the story RBAC
# (free < premium < admin). Users may be in multiple groups; the highest wins.
AUTH_ROLE_GROUPS = {
    "admin": "admin",
    "premium": "premium",
    "free": "free",
}

# Per-user context files are stored at ~/local-ai-files/contexts/<user>.txt
# (the users.json "context_file" field is gone along with users.json).
CONTEXTS_DIR = os.environ.get("CONTEXTS_DIR", os.path.expanduser("~/local-ai-files/contexts"))

# Which llama-server self-chat agents run on: "cpu" (the RAM-backed CPU server
# on 8079, so agents never compete with interactive UI users for VRAM) or "gpu"
# (the interactive GPU server on 8081, sharing the VRAM-backed model). Override
# with the SELF_CHAT_MODE environment variable.
SELF_CHAT_MODE = os.environ.get("SELF_CHAT_MODE", "cpu").strip().lower()
if SELF_CHAT_MODE not in ("cpu", "gpu"):
    SELF_CHAT_MODE = "cpu"

# Test-time flag: flip to True (manually) to keep EVERY request on the fast GPU
# lane and never admit anything — including self-chat agents — to the slow CPU
# lane. During testing it is easier to wait a few seconds for the GPU than to
# endure CPU speed. A real web-UI human request never goes to the CPU lane
# regardless of this flag: that invariant is enforced unconditionally at
# admission and in task_mode().
FORCE_GPU_LANE = True

# Research self-verification ("critic" pass). After a research answer is
# generated, each inline "(Author, Venue, Year) [url]" citation is re-fetched
# and checked by a second LLM call. These bounds are per-citation only — there
# is deliberately NO overall cap on a report's verification budget.
VERIFY_RETRIES = 2          # extra search/fetch attempts per citation
VERIFY_FETCH_CHARS = 6000   # source text shown to the critic LLM per citation
VERIFY_MAX_CITES_PER_URL = 3  # flag a source cited for more distinct claims than this

# Review-only self-chat roles that must NEVER call tools. The editor/moderator
# are the same creative LLM as the story-writing agents, and with the tool list
# enabled (tool_choice "auto") they spontaneously call generate_image/edit_image
# while revising markdown, burning ComfyUI VRAM on unwanted images. Their chat
# requests are sent with an empty tool list and tool_choice "none".
TOOL_FREE_AGENTS = {"editor", "moderator"}

# model.json holds the LLM model filenames (relative to ~/local-ai-files/my-models/)
# per runtime mode: "gpu" for interactive chat UI users, "cpu" for automated
# self-chat agents (editor/moderator/registered agents). Falls back to the legacy
# single-model model.txt when model.json is missing or has no usable entries.
MODEL_CONFIG_FILE = os.path.expanduser("~/local-ai-files/model.json")


def _load_model_ids(model_file, legacy_file):
    """Return the (gpu, cpu) model ids for the given config files.

    ``model_file`` is the JSON config holding per-mode ids; ``legacy_file`` is
    the plain-text single-model file used as a fallback.
    """
    gpu = ""
    cpu = ""
    try:
        with open(model_file, "r", encoding="utf-8") as file:
            data = json.load(file)
        if isinstance(data, dict):
            gpu = str(data.get("gpu") or data.get("default") or "").strip()
            cpu = str(data.get("cpu") or gpu).strip()
    except (FileNotFoundError, json.JSONDecodeError):
        pass
    if not gpu:
        try:
            with open(legacy_file, "r") as file:
                gpu = file.read().strip()
        except (FileNotFoundError, OSError):
            pass
        if not cpu:
            cpu = gpu
    return gpu, cpu


MODEL_ID, MODEL_ID_CPU = _load_model_ids(
    MODEL_CONFIG_FILE, os.path.expanduser("~/local-ai-files/model.txt")
)

COMFYUI_OUTPUT = os.path.expanduser("~/local-ai-files/ComfyUI/output")
UPLOADS_DIR = os.path.expanduser("~/local-ai-files/uploads")
LLAMA_SERVER_PATH = os.path.expanduser("~/local-ai/llama.cpp/build/bin/llama-server")

# ─────────────────────────────────────────────────────────────────────────────
# KV-cache slot checkpoints (--slot-save-path).
#
# The GPU llama-server is unloaded from VRAM for every ComfyUI render (and both
# servers idle-unload after 300s), which throws away the KV cache of the whole
# conversation prefix. With --slot-save-path the server exposes
# POST /slots/{id}?action=save|restore, so llm.py snapshots the KV to this
# directory right before an unload and restores it after the model loads again
# — the next completion then only evaluates NEW tokens instead of re-prefilling
# the entire context.
#
# The directory MUST exist before llama-server starts: its arg parser rejects
# a missing --slot-save-path directory at startup, hence the makedirs here.
# ─────────────────────────────────────────────────────────────────────────────
LLAMA_SLOT_SAVE_DIR = os.environ.get(
    "LLAMA_SLOT_SAVE_DIR", os.path.expanduser("~/local-ai-files/kv-slots")
)
os.makedirs(LLAMA_SLOT_SAVE_DIR, exist_ok=True)

LLAMA_QWEN_NGL = "0"
LLAMA_GEMMA_NGL = "99"
LLAMA_SERVER_ARGS = [
    "--host", "0.0.0.0",
    "--port", "8081",
    "--models-dir", os.path.expanduser("~/local-ai-files/my-models/"),
    "--jinja",

    # GPU / VRAM & Performance
    "-ngl", LLAMA_GEMMA_NGL,
    "-fa", "on",
    "--ctx-size", "24576",       # 24K context for interactive UI chat
    "-ctk", "q8_0",
    "-ctv", "q8_0", # If you really need a very big context on VRAM, can make it q8_0
    "--no-mmproj-offload",

    # Threads & Batching
    "-t", "8",
    "-tb", "8",
    "-ub", "512",
    "--timeout", "3600",

    # KV-cache checkpointing: enables POST /slots/{id}?action=save|restore so
    # the conversation KV survives model unload/reload cycles (image gen).
    # The router passes this down to each loaded model instance.
    "--slot-save-path", LLAMA_SLOT_SAVE_DIR,

    # Sampling Parameters
    "--temp", "1.0",
    "--top-p", "0.95",
    "--top-k", "64",
    "--min-p", "0.05"
]

# Second set of llama-server arguments used when processing automated
# self-chat messages (editor/moderator/agent runs). These are background,
# non-interactive jobs, so they deliberately run the model on the CPU only —
# slower, but they never compete with interactive users for VRAM. This server
# runs on its own port (8079) CONCURRENTLY with the GPU server on 8081, so the
# two are started and stopped independently (see restart_llama_server).
LLAMA_SERVER_ARGS_CPU = [
    "--host", "0.0.0.0",
    "--port", "8079",
    "--models-dir", os.path.expanduser("~/local-ai-files/my-models/"),
    "--jinja",

    # CPU-only execution — no layers offloaded to the GPU.
    "--n-gpu-layers", "0",
    "-fa", "off",
    "--ctx-size", "65536",
    "-ctk", "q8_0",            # Quantized KV cache keeps RAM usage low
    # Keep the multimodal projector (mmproj) in RAM too. llama-server
    # offloads the mmproj to the GPU by DEFAULT even with --n-gpu-layers 0,
    # which cudaMalloc-OOMs on the 4 GiB card while the GPU server is loaded.
    "--no-mmproj-offload",

    "-t", "6",
    "-tb", "6",

    # Reasoning & Thinking Limits
    "--reasoning-budget", str(REASONING_BUDGET),
    "--reasoning-budget-message", "Reasoning limit reached, summarize final answer.",

    "--temp", "1.0",
    "--top-p", "0.95",
    "--top-k", "64",
    "--min-p", "0.0",
    "--repeat-penalty", "1.0",
    "--device", "none",

    # KV-cache checkpointing (see LLAMA_SERVER_ARGS): the CPU lane also
    # idle-unloads, and re-prefilling an agent story context on CPU is slow.
    "--slot-save-path", LLAMA_SLOT_SAVE_DIR,
]

FILES_DIR = os.path.expanduser("~/local-ai-files")
SESSIONS_DIR = os.path.join(FILES_DIR, "session")
SESSIONS_FILE = os.path.join(SESSIONS_DIR, "sessions.json")
SHARES_FILE = os.path.join(FILES_DIR, "shares.json")
IMG_PATH = os.path.expanduser("~/local-ai-files/ComfyUI/output")
COMFYUI_INPUT = os.path.expanduser("~/local-ai-files/ComfyUI/input")
PROMPT_PATH = os.path.expanduser("~/local-ai-files/sys_prompt.txt")
TASKS_DB = os.path.expanduser("~/local-ai-files/tasks.db")
THEMES_DB = os.path.expanduser("~/local-ai-files/themes.db")
IMAGE_TOKEN_COST = 1200
AUDIO_TOKEN_COST = 800
PER_MESSAGE_OVERHEAD = 4

with open(
    os.path.expanduser("~/local-ai-files/models.json"), "r", encoding="utf-8"
) as file:
    IMAGE_MODELS = json.load(file)

TOOLS = [
        {
            "type": "function",
            "function": {
                "name": "web_search",
                "description": "Search the web for real-time/current information. Use this for weather, news, sports, stock prices, recent events, or any query where up-to-date data matters. Do NOT answer time-sensitive questions from memory — always search. The results contain snippets only; if the snippets are insufficient to answer the question fully, follow up with fetch_page to read the full content of the relevant page.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "query": {"type": "string", "description": "The search query"},
                        "current_time": {"type": "string", "description": "Current date and time. Pass ONLY for time-sensitive queries (news, events, hours, etc.) where recency matters. Omit for direct-link lookups or general information."},
                        "current_location": {"type": "string", "description": "User's location. Pass ONLY for location-specific results (weather, local news, nearby places, events). If you don't know the user's location, call get_user_location first to obtain it. Do NOT guess or fabricate location."}
                    },
                    "required": ["query"],
                },
            },
        },
    {
        "type": "function",
        "function": {
            "name": "fetch_page",
            "description": "Fetch and read the full text content of a web page. Use this AFTER web_search when the search snippets are not enough to answer the question (e.g. you need details, data, or an article's body). Pass the full URL of the page to read. Long pages are returned one chunk at a time; if the result reports total_chunks greater than 1, call fetch_page again with chunk=2, 3, ... to read the rest. PDFs with no extractable text expose page_images rendered from the scanned pages.",
            "parameters": {
                "type": "object",
                "properties": {
                    "url": {
                        "type": "string",
                        "description": "The full URL of the web page to fetch (must start with http:// or https://)."
                    },
                    "chunk": {
                        "type": "integer",
                        "description": "Which chunk of the page to read (1 = first). Omit to read the first chunk."
                    }
                },
                "required": ["url"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "generate_image",
            "description": "Generate or draw an image. You MUST choose a style model.",
            "parameters": {
                "type": "object",
                "properties": {
                    "prompt": {
                        "type": "string",
                        "description": "Detailed visual description of what to draw/generate.",
                    },
                    "negative_prompt": {
                        "type": "string",
                        "description": "Things to avoid in the image",
                    },
                    "model": {
                        "type": "string",
                        "enum": list(IMAGE_MODELS.keys()),
                        "description": "Art style to use. Options: "
                        + ", ".join(
                            [
                                f"'{k}' ({v['description']})"
                                for k, v in IMAGE_MODELS.items()
                            ]
                        ),
                    },
                    "aspect_ratio": {
                        "type": "string",
                        "enum": ["landscape", "portrait", "square"],
                        "description": "Image framing/aspect ratio. "
                        "landscape = wide scene (default), portrait = tall or "
                        "single-subject close-up, square = balanced illustration.",
                    },
                },
                "required": ["prompt", "model"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "edit_image",
            "description": "Generic Img2Img image editor to modify, restyle, recolor, add elements, or transform existing or uploaded images.",
            "parameters": {
                "type": "object",
                "properties": {
                    "prompt": {
                        "type": "string",
                        "description": "Complete description of what the edited image should look like.",
                    },
                    "negative_prompt": {
                        "type": "string",
                        "description": "Elements to exclude from the visual generation.",
                    },
                    "denoise": {
                        "type": "number",
                        "description": "Denoising value (0.1 to 1.0). Use 0.25-0.4 for subtle color/lighting changes, 0.45-0.65 for structural edits and object additions, and 0.7-0.85 for massive re-imaginings.",
                    },
                },
                "required": ["prompt", "denoise"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "get_user_location",
            "description": "Request the user's current geographical location. Call this ONLY when you need location for a location-specific query (weather, local news, nearby places, etc.) and you don't already have the user's location. Returns the user's city/area or 'denied' if they refuse.",
            "parameters": {
                "type": "object",
                "properties": {},
                "required": [],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "Read the text content of an uploaded file (PDF, DOC, DOCX, XLS, XLSX). Call this when the user has attached a file and you need to read its content to answer their question. The file URL is provided in the user message as [FILE: url]. Pass that url as the file_url parameter.",
            "parameters": {
                "type": "object",
                "properties": {
                    "file_url": {
                        "type": "string",
                        "description": "The file URL from the user message (the /uploads/... path)."
                    }
                },
                "required": ["file_url"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "read_image",
            "description": "View/read an image that was attached or generated earlier in the conversation. The image URL appears in the conversation as [IMAGE: url]. Call this when you actually need to see the image content to answer or describe it accurately. Pass that url as the url parameter.",
            "parameters": {
                "type": "object",
                "properties": {
                    "url": {
                        "type": "string",
                        "description": "The image URL from the conversation, e.g. /uploads/<file>.jpg or /output/<file>.png"
                    }
                },
                "required": ["url"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "update_user_context",
            "description": "Store information about the current user that persists across conversations. Saves preferences, personal details, important facts, or anything the user should not need to repeat. This APPENDS to existing context — only add NEW information, do not repeat what was already saved.",
            "parameters": {
                "type": "object",
                "properties": {
                    "content": {
                        "type": "string",
                        "description": "The new information to append to the user's context. Keep it concise and focused on what's new.",
                    }
                },
                "required": ["content"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "manage_tasks",
            "description": "Manage to-do tasks. Can create, update, complete, delete, list, or get task details. Use this when the user wants to track tasks, set reminders, or manage their to-do list.",
            "parameters": {
                "type": "object",
                "properties": {
                    "operation": {
                        "type": "string",
                        "enum": ["create", "update", "complete", "delete", "list", "get"],
                        "description": "The operation to perform.",
                    },
                    "task_id": {
                        "type": "string",
                        "description": "Required for update/complete/delete/get. The task ID.",
                    },
                    "title": {
                        "type": "string",
                        "description": "Required for create. Task title.",
                    },
                    "description": {
                        "type": "string",
                        "description": "Task description or details.",
                    },
                    "priority": {
                        "type": "string",
                        "enum": ["low", "medium", "high"],
                        "description": "Task priority (default: medium).",
                    },
                    "status": {
                        "type": "string",
                        "enum": ["pending", "in_progress", "completed", "cancelled"],
                        "description": "For update: new status.",
                    },
                    "due_date": {
                        "type": "string",
                        "description": "Due date in ISO format (e.g. 2026-08-15T17:00:00).",
                    },
                    "reminder_at": {
                        "type": "string",
                        "description": "Reminder time in ISO format. The system will notify about this task at the given time.",
                    },
                    "session_id": {
                        "type": "string",
                        "description": "Session ID to link this task to a conversation.",
                    },
                },
                "required": ["operation"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "track_theme",
            "description": "Dedicated theme/combination tracker that guarantees creative variety across all generated content. It records every already-used combination of task-detail fields + mood + genre + role + persona, both globally (across ALL users) and per-user scope. Call BEFORE agreeing on a creative idea to see what has already been produced (never repeat it), and call again AFTER locking in an idea to log it. Use this INSTEAD of manage_tasks for theme/idea tracking.",
            "parameters": {
                "type": "object",
                "properties": {
                    "operation": {
                        "type": "string",
                        "enum": ["list", "log", "complete", "check", "stats"],
                        "description": "The operation to perform.",
                    },
                    "scope": {
                        "type": "string",
                        "description": "Which user scope the theme belongs to (the user whose content is being generated). For the self-chat window always use 'self-chat' so all agents share one history. Optional for list/stats, required for log/check.",
                    },
                    "global": {
                        "type": "boolean",
                        "description": "list/stats only: include the history across ALL users instead of just scope. Use to keep track of all users at a glance.",
                    },
                    "theme": {
                        "type": "string",
                        "description": "log only: a short 3-6 word slug of the concrete creative theme/premise/idea chosen.",
                    },
                    "genre": {
                        "type": "string",
                        "description": "log/check only: the task genre.",
                    },
                    "mood": {
                        "type": "string",
                        "description": "log/check only: the mood/tone used.",
                    },
                    "role": {
                        "type": "string",
                        "description": "log/check only: the relationship dynamic or role pair used.",
                    },
                    "persona": {
                        "type": "string",
                        "description": "log/check only: the persona(s) used.",
                    },
                    "details": {
                        "type": "object",
                        "description": "log/check only: the resolved task-detail fields as {field: value} pairs.",
                    },
                    "theme_id": {
                        "type": "string",
                        "description": "complete only: the id of the theme record to mark completed.",
                    },
                    "status": {
                        "type": "string",
                        "enum": ["active", "completed"],
                        "description": "log only: initial status (default: active).",
                    },
                    "limit": {
                        "type": "integer",
                        "description": "list only: max number of records to return (default 50).",
                    },
                },
                "required": ["operation"],
            },
        },
    },
]

TOOLS_TOKEN_COST = len(json.dumps(TOOLS)) // 4


def build_sys_content():
    with open(PROMPT_PATH, "r") as file:
        sys_content = file.read()
    model_list = "; ".join(f"{k}: {v['description']}" for k, v in IMAGE_MODELS.items())
    sys_content = sys_content.replace("%model_list%", model_list)
    sys_content = sys_content.replace("%_image_keys%", str(list(IMAGE_MODELS.keys())))
    return sys_content
</file>

<file path="self-chat.py">
import os
import time
import json
import base64
import argparse
import requests
import re
import shutil
import threading
import traceback
from difflib import SequenceMatcher
from datetime import datetime
import random

from server.dotenv import load_dotenv

load_dotenv()

parser = argparse.ArgumentParser(description="Self-chat story generator")
parser.add_argument(
    "--config",
    default="",
    help="Path to a custom JSON task list to use instead of the default "
    "(~/local-ai-files/tasks.json). The file may be a plain task list or an "
    "object with 'tasks' plus optional 'genre_checklists'. Each task may also "
    "carry its own 'checklist' (editor/moderator) that wins over the genre "
    "checklist. Combine with --defaults to also include the default tasks.",
)
parser.add_argument(
    "--defaults",
    action="store_true",
    help="Also load the default tasks (~/local-ai-files/tasks.json) in addition "
    "to the ones from --config.",
)
parser.add_argument(
    "--dry-run",
    action="store_true",
    help="Validate the task config and environment and print the full plan "
    "for each task (genre/checklist resolution, script enforcement, medium "
    "feasibility) without making any LLM call, then exit.",
)
parser.add_argument(
    "--gpu",
    action="store_true",
    help="Run the self-chat agents (kolpo/kaya/editor/moderator) on the "
    "interactive GPU llama-server (8081) instead of the RAM-backed CPU "
    'server (8079). Each /api/chat request carries mode="gpu" so '
    "chat-webui routes the agent tasks to the GPU lane.",
)
args = parser.parse_args()
STORY_BASE_DIR = os.path.expanduser("~/local-ai-files/stories")

# Tiered story roots for premium/admin tasks whose spec declares no path.
# Resolved the same way markdown_hosting.py resolves its collections: from
# the OS environment (STORIES_PREMIUM_DIR / STORIES_ADMIN_DIR), falling back
# to the shared free stories dir when unset.
PREMIUM_STORIES_DIR = os.getenv("STORIES_PREMIUM_DIR")
ADMIN_STORIES_DIR = os.getenv("STORIES_ADMIN_DIR")

# Agents normally run on this machine next to chat-webui; override with
# SELF_CHAT_BASE_URL only when pointing them somewhere else.
BASE_URL = os.environ.get(
    "SELF_CHAT_BASE_URL", "http://localhost:3001"
).rstrip("/")
USERNAME_A = "kolpo"
USERNAME_B = "kaya"
# Each agent is a real Authentik user with its own password. Use the shared
# SELF_CHAT_PASSWORD as a fallback when the per-agent override is unset.
_SHARED_PASSWORD = os.environ.get("SELF_CHAT_PASSWORD", "")
PASSWORD_A = os.environ.get("SELF_CHAT_A_PASSWORD", _SHARED_PASSWORD)
PASSWORD_B = os.environ.get("SELF_CHAT_B_PASSWORD", _SHARED_PASSWORD)

STOP_PHRASE = "[END CONVERSATION]"
POLL_INTERVAL_SECONDS = 5.0
SLEEP_BETWEEN_TURNS = 30.0
MAX_MESSAGES_PER_AGENT = 10
AGENT_NAMES = {"A": "Kolpo", "B": "Kaya"}
SELF_CHAT_PROMPT_FILE = "/home/palash/local-ai-files/self_chat.txt"
STARTING_CONVERSATION = open(SELF_CHAT_PROMPT_FILE).read()

SLEEP_BETWEEN_ROUNDS = 900

USERNAME_EDITOR = "editor"
USERNAME_MODERATOR = "moderator"
PASSWORD_EDITOR = os.environ.get("SELF_CHAT_EDITOR_PASSWORD", _SHARED_PASSWORD)
PASSWORD_MODERATOR = os.environ.get("SELF_CHAT_MODERATOR_PASSWORD", _SHARED_PASSWORD)
EDITOR_PROMPT_FILE = "/home/palash/local-ai-files/contexts/editor.txt"
MODERATOR_PROMPT_FILE = "/home/palash/local-ai-files/contexts/moderator.txt"
CRITIQUE_PROMPT_FILE = "/home/palash/local-ai-files/contexts/critique.txt"

# Kaya↔Kolpo cross-critique: at most this many retries on the same failing spot
# before giving up and letting the deterministic gate auto-RED the story.
MAX_CRITIQUE_RETRIES = 2

DEFAULT_TASKS_FILE = os.path.expanduser("~/local-ai-files/tasks.json")

# All participants of the self-chat window (kolpo, kaya, editor, moderator)
# share one theme scope, so the theme is coordinated between the users of the
# window while regular per-user chats stay isolated in their own scopes.
SELF_CHAT_THEME_SCOPE = "self-chat"
SELF_CHAT_THEME_LIMIT = 30
# How many times a per-turn detail combination may be re-rolled if the theme
# tracker reports it was already used.
MAX_THEME_REROLL = 4


GENRE_CHECKLISTS_FILE = os.path.expanduser("~/local-ai-files/genre_checklists.json")


GENRE_PERSONA_MAP_FILE = os.path.expanduser(
    "~/local-ai-files/contexts/genre_persona_map.json"
)
PERSONA_POOL_FILE = os.path.expanduser("~/local-ai-files/contexts/persona_pool.json")

_persona_cycles = {}


def load_json_file(filepath, fallback):
    try:
        with open(filepath, encoding="utf-8") as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError) as e:
        print(f"[persona] Could not load {filepath}: {e} — using fallback")
        return fallback


GENRE_PERSONA_MAP = load_json_file(GENRE_PERSONA_MAP_FILE, {})
PERSONA_POOL = load_json_file(PERSONA_POOL_FILE, {})
MASTER_DETAILS = load_json_file(
    os.path.expanduser("~/local-ai-files/contexts/master_details.json"), {}
)


def pick_persona_round_robin(pool, genre, genre_map, task_roles=None):
    global _persona_cycles
    allowed = genre_map.get(genre) or genre_map.get("default")

    # Hard safety rule: exclude Parent & Child from Adventure & Horror
    if genre == "Adventure & Horror" and allowed:
        allowed = [r for r in allowed if r != "Parent & Child"]

    task_roles = task_roles or []

    # Flatten pool: (relationship, mood, details_dict)
    candidates = []
    for rel, moods in pool.items():
        if allowed and rel not in allowed:
            continue
        for mood, details in moods.items():
            req_role = details.get("required_role")

            # If profile requires premium/admin, skip if current task roles don't match
            if req_role:
                is_premium_or_admin = any(r in task_roles for r in ["premium", "admin"])
                if req_role == "premium" and not is_premium_or_admin:
                    continue
                elif req_role == "admin" and "admin" not in task_roles:
                    continue
            candidates.append((rel, mood, details))

    if not candidates:
        # Fallback default
        fallback_details = {
            "Kaya": {
                "role": "Colleague",
                "persona": "Creative and energetic problem solver",
            },
            "Kolpo": {
                "role": "Colleague",
                "persona": "Methodical and structured partner",
            },
        }
        return "Colleagues", "Focused Collaboration", fallback_details

    if genre not in _persona_cycles or _persona_cycles[genre]["idx"] >= len(
        _persona_cycles[genre]["pairs"]
    ):
        shuffled = list(candidates)
        random.shuffle(shuffled)
        _persona_cycles[genre] = {"pairs": shuffled, "idx": 0}

    state = _persona_cycles[genre]
    choice = state["pairs"][state["idx"]]
    state["idx"] += 1
    return choice  # Returns (relationship, mood, details_dict)


def deep_merge(target, source):
    """Recursively merge dictionary source into target."""
    for key, value in source.items():
        if isinstance(value, dict) and key in target and isinstance(target[key], dict):
            deep_merge(target[key], value)
        else:
            target[key] = value
    return target


def load_genre_checklists(extra=None):
    """Base checklists from genre_checklists.json, overridden per genre by any
    'genre_checklists' carried in a task config file."""
    checklists = {}
    try:
        with open(GENRE_CHECKLISTS_FILE, encoding="utf-8") as f:
            checklists = json.load(f)
    except (FileNotFoundError, json.JSONDecodeError) as e:
        print(
            f"[checklist] Could not load {GENRE_CHECKLISTS_FILE}: {e} — using empty checklists"
        )
    if extra:
        checklists.update(extra)
    return checklists


def _parse_tasks(items):
    tasks = []
    for item in items:
        task = (item.get("task") or "").strip()
        if not task:
            continue
        languages = item.get("languages") or ["English"]
        if isinstance(languages, str):
            languages = [l.strip() for l in languages.split(",")]
        mediums = item.get("mediums") or ["image", "text"]
        if isinstance(mediums, str):
            mediums = [m.strip() for m in mediums.split(",")]
        roles = item.get("roles") or ["free"]
        if isinstance(roles, str):
            roles = [r.strip() for r in roles.split(",")]
        genre = (item.get("genre") or "").strip() or "General"
        details = item.get("details") or ""
        if isinstance(details, str):
            details = details.strip()
        checklist = item.get("checklist") or {}
        path = (item.get("path") or "").strip() or None
        inactive = item.get("inactive") or False
        context = item.get("context") or None
        turns = MAX_MESSAGES_PER_AGENT
        raw_turns = item.get("turns")
        research = bool(item.get("research"))
        research_turns = int(item.get("research_turns") or 1)
        if research_turns < 1:
            research_turns = 1
        if raw_turns is not None:
            try:
                turns = int(raw_turns)
            except (TypeError, ValueError):
                turns = MAX_MESSAGES_PER_AGENT
            if turns < 2:
                turns = MAX_MESSAGES_PER_AGENT
        tasks.append(
            {
                "task": task,
                "genre": genre,
                "languages": languages,
                "mediums": mediums,
                "roles": roles,
                "details": details,
                "checklist": checklist,
                "path": path,
                "inactive": inactive,
                "context": context,
                "turns": turns,
                "research": research,
                "research_turns": research_turns,
            }
        )
    return tasks


_detail_cycles = {}


def _fmt_detail_value(value):
    """Render a resolved detail value as prompt text."""
    if isinstance(value, str):
        return value
    if isinstance(value, (int, float, bool)) or value is None:
        return str(value)
    if isinstance(value, (dict, list)):
        return json.dumps(value)
    return str(value)


def _join_values(values, separator=None):
    """Join multi-selected values naturally: 'a', 'a and b', 'a, b and c'."""
    if separator:
        return separator.join(values)
    if len(values) <= 1:
        return values[0] if values else ""
    return ", ".join(values[:-1]) + " and " + values[-1]


def _resolve_count(task, name, spec):
    """Resolve how many values a multi selector should pick.

    ``count`` may be a plain integer/string or its own selector spec
    (e.g. ``{"selector": "random", "values": [2, 3]}``), resolved through
    the same selector machinery so it can vary per round.
    """
    count = spec.get("count")
    if count is None:
        return 1
    count_spec = count if isinstance(count, dict) else {"value": count}
    resolved = _pick_detail_value(task, f"{name}::count", count_spec)
    if isinstance(resolved, (list, tuple)):
        return len(resolved)
    try:
        return max(0, int(resolved))
    except (TypeError, ValueError):
        return 1


def _pick_detail_value(task, name, spec):
    """Pick value(s) for one detail field according to its selector.

    Supported selectors:
      - random:           pick one value at random (fresh each round)
      - roundrobin:       cycle through values one at a time across rounds
      - random_multi:     pick ``count`` distinct values at random
      - roundrobin_multi: slide a window of ``count`` values across rounds
      - absent / static / first: use ``value`` if given, else ``values[0]``
    """
    if "value" in spec:
        return spec["value"]

    values = spec.get("values") or []
    selector = str(spec.get("selector") or "").strip().lower()

    if not values:
        return None

    count = _resolve_count(task, name, spec) if spec.get("count") is not None else None
    if count is not None and count > 1:
        if selector in ("roundrobin", "roundrobin_multi"):
            key = (task, name)
            n = len(values)
            start = _detail_cycles.get(key, 0) % n
            _detail_cycles[key] = start + count
            return [values[(start + i) % n] for i in range(min(count, n))]
        return random.sample(values, min(count, len(values)))

    if selector == "random":
        return random.choice(values)

    if selector == "roundrobin":
        key = (task, name)
        idx = _detail_cycles.get(key, 0) % len(values)
        _detail_cycles[key] = idx + 1
        return values[idx]

    if selector == "random_multi":
        count = _resolve_count(task, name, spec)
        return random.sample(values, min(count, len(values)))

    if selector == "roundrobin_multi":
        count = _resolve_count(task, name, spec)
        key = (task, name)
        n = len(values)
        start = _detail_cycles.get(key, 0) % n
        _detail_cycles[key] = start + count
        return [values[(start + i) % n] for i in range(min(count, n))]

    return values[0]


def _merge_value_defs(spec, master, _seen=None):
    """Resolve ``ref`` / ``refs`` against the master dictionary.

    The ``values`` across every referenced master definition are unioned
    (order-preserving and deduplicated) so a single field can draw from several
    value pools at once. Resolution is recursive: a referenced definition may
    itself carry ``ref`` / ``refs``, allowing one value set to be composed from
    others. ``_seen`` guards against reference cycles.

    As before, an explicit inline ``values`` list on the spec wins outright over
    the merged pool (mirroring the old single-``ref`` override behaviour). The
    returned dict is ready to hand to :func:`_pick_detail_value`.
    """
    if not isinstance(spec, dict):
        return spec
    _seen = set() if _seen is None else _seen

    ref = spec.get("ref")
    refs = spec.get("refs")
    if refs is not None:
        if isinstance(refs, (str, bytes)):
            refs = [refs]
        else:
            refs = list(refs)
        if ref is not None and ref not in refs:
            refs.insert(0, ref)
    elif ref is not None:
        refs = [ref]
    else:
        refs = []

    merged = dict(spec)
    if not refs:
        return merged

    values = list(spec.get("values") or [])
    selector = merged.get("selector")
    name = merged.get("name")
    count = merged.get("count")
    separator = merged.get("separator")

    for key in refs:
        if key in _seen:
            continue
        entry = master.get(key) if isinstance(master, dict) else None
        if not isinstance(entry, dict):
            continue
        sub = _merge_value_defs(entry, master, _seen | {key})
        for v in sub.get("values") or []:
            if isinstance(v, (list, tuple, dict)) or v in values:
                continue
            values.append(v)
        if selector is None:
            selector = sub.get("selector")
        if name is None:
            name = sub.get("name")
        if count is None:
            count = sub.get("count")
        if separator is None:
            separator = sub.get("separator")

    if spec.get("values") is not None:
        values = list(spec["values"])

    if spec.get("name") is not None:
        name = spec["name"]

    if values:
        merged["values"] = values
    else:
        merged.pop("values", None)
    if selector is not None:
        merged["selector"] = selector
    if name is not None:
        merged["name"] = name
    if count is not None:
        merged["count"] = count
    if separator is not None:
        merged["separator"] = separator
    merged.pop("ref", None)
    merged.pop("refs", None)
    return merged


def _pick_when_branch(table, value):
    """Select a ``when`` branch by exact match on a resolved value.

    ``*`` acts as a fallback when no exact branch matches. If the trigger value
    is a list (multi-select), any one of its elements may match.
    """
    if not isinstance(table, dict):
        return None
    if isinstance(value, (list, tuple)):
        candidates = [v for v in value if v is not None]
    elif value is not None:
        candidates = [value]
    else:
        candidates = []
    for candidate in candidates:
        if str(candidate) in table:
            return table[str(candidate)]
    if "*" in table:
        return table["*"]
    return None


def _resolve_when_spec(spec, task, master, trigger_values):
    """Resolve a spec's ``when`` conditions into a value-definition dict.

    Each ``when`` entry maps an already-resolved field name to a branch table
    (``{value: def}``). One branch is chosen per trigger field; a field with
    multiple triggers ANDs its branches together by unioning their pools. A
    missing trigger (or unmatched value without a ``*`` fallback) skips the
    field entirely by returning ``None``.
    """
    when = spec.get("when")
    if not isinstance(when, dict) or not when:
        return None

    branches = []
    for trigger, table in when.items():
        if not isinstance(table, dict):
            continue
        branch = _pick_when_branch(table, trigger_values.get(trigger))
        if branch is None:
            return None
        branches.append(branch)

    if not branches:
        return None
    if len(branches) == 1:
        return dict(branches[0]) if isinstance(branches[0], dict) else branches[0]

    refs, values = [], []
    seen_values = set()
    selector = name = count = separator = None
    for branch in branches:
        if not isinstance(branch, dict):
            continue
        branch_refs = branch.get("refs")
        if branch_refs is None and branch.get("ref") is not None:
            branch_refs = [branch["ref"]]
        if isinstance(branch_refs, (str, bytes)):
            branch_refs = [branch_refs]
        for r in branch_refs or []:
            if r not in refs:
                refs.append(r)
        for v in branch.get("values") or []:
            if isinstance(v, (list, tuple, dict)):
                continue
            if v not in seen_values:
                seen_values.add(v)
                values.append(v)
        if selector is None:
            selector = branch.get("selector")
        if name is None:
            name = branch.get("name")
        if count is None and branch.get("count") is not None:
            count = branch.get("count")
        if separator is None and branch.get("separator") is not None:
            separator = branch.get("separator")

    merged = {}
    if refs:
        merged["refs"] = refs
    if values:
        merged["values"] = values
    for key, val in (
        ("selector", selector),
        ("name", name),
        ("count", count),
        ("separator", separator),
    ):
        if val is not None:
            merged[key] = val
    return merged or None


def _details_specs(details):
    """Normalize a ``details`` value into a list of field spec dicts."""
    if isinstance(details, list):
        return details
    if isinstance(details, dict):
        return [
            {"name": name, **(spec if isinstance(spec, dict) else {"value": spec})}
            for name, spec in details.items()
        ]
    return []


def _spec_in_filter(spec, freq_filter):
    """Whether a field spec belongs to the given change-frequency pass.

    Fields without an explicit ``change_freq`` default to ``"Per Round"``;
    only genuinely ``"Per Turn"`` fields join the per-turn re-resolution.
    """
    if not isinstance(spec, dict):
        return False
    if freq_filter is None:
        return True
    eff = str(spec.get("change_freq") or "Per Round")
    if freq_filter == "Per Round":
        return eff == "Per Round"
    if freq_filter == "Per Turn":
        return eff == "Per Turn"
    return eff == freq_filter


def _has_per_turn_details(details):
    """True if a task spec has any genuinely per-turn field."""
    return any(_spec_in_filter(s, "Per Turn") for s in _details_specs(details))


def _character_fields(details_spec):
    """Field specs flagged ``character: true`` — the story's named cast."""
    return [
        s
        for s in _details_specs(details_spec)
        if isinstance(s, dict) and s.get("character")
    ]


def _pick_character_name(task, field_name, names, skip=()):
    """Pick a character name for the round (round-robins across rounds).

    ``skip`` is a set of names already taken this round, so multi-member cast
    slots (``count > 1``) never reuse a name within the same round.
    """
    values = [
        n for n in (names or []) if isinstance(n, str) and n.strip() and n not in skip
    ]
    if not values:
        return ""
    key = (task, field_name, "<name>")
    idx = _detail_cycles.get(key, 0) % len(values)
    _detail_cycles[key] = idx + 1
    return values[idx]


def build_cast(task, details_spec, round_fields):
    """Decide and NAME the story's characters for this round.

    Returns a list of ``(label, species, name)`` triples: one per field flagged
    ``character: true``, expanded into ``count`` members when the field carries a
    count. Species come from the already-resolved per-round value pool (a list
    means a multi-member slot); names are assigned deterministically from the
    field's ``names`` list (rotating across rounds, never reused within a round),
    so the cast — including its size — is fixed and repeatable before a single
    word of story is written.
    """
    cast = []
    fields = round_fields or {}
    for spec in _character_fields(details_spec):
        label = str(spec.get("name") or "").strip()
        value = fields.get(label, spec.get("value"))
        species_list = value if isinstance(value, (list, tuple)) else [value]
        used = set()
        for species in species_list:
            species = str(species or "").strip()
            if not species:
                continue
            name = _pick_character_name(task, label, spec.get("names"), skip=used)
            if name:
                used.add(name)
            cast.append((label, species, name or species))
    return cast


def format_cast_block(cast):
    """Render the decided-and-named cast as an immutable per-turn directive."""
    if not cast:
        return ""
    lines = [
        "## Characters (already decided and named for this story — immutable)",
    ]
    for label, species, name in cast:
        lines.append(f"- {name} — the {label}, {species}")
    lines.append(
        "HARD RULE: These are the ONLY characters in this story. Never create, "
        "name, or depict any additional character in the text or in any image; "
        "every generated image must show only these named characters."
    )
    return "\n".join(lines)


def _resolve_field_value(spec, task, master):
    """Resolve a single detail field spec into ``(name, value)``."""
    if not isinstance(spec, dict):
        return "", str(spec)

    refs = spec.get("refs")
    merged = _merge_value_defs(spec, master)

    # If name wasn't explicitly provided in spec, fall back to master's name,
    # then to the first referenced pool key.
    name = str(merged.get("name") or spec.get("name") or "").strip()
    if not name:
        first_ref = (
            refs[0] if isinstance(refs, list) and refs else spec.get("ref") or ""
        )
        name = str(first_ref or "").strip()

    value = _pick_detail_value(task, name, merged)
    return name, value


def resolve_details(
    details, task, master=None, freq_filter="Per Round", preferred=None
):
    """Resolve field specs matching a specific change frequency.

    Fields carrying a ``when`` block are resolved in a second pass, once the
    plain fields they depend on have been resolved (their values form the
    trigger map). A trigger field is resolved exactly once even though it lives
    in both passes (its result is cached).

    ``preferred`` may carry already-resolved ``{name: value}`` pairs (e.g. from
    :func:`resolve_details_fields`) that should be reused verbatim instead of
    being resolved again, so the rendered prompt text always matches the values
    that were tracked. When ``preferred`` is given the when-trigger pass is
    skipped, since every value is already final.
    """
    if master is None:
        master = MASTER_DETAILS

    if isinstance(details, str):
        return details if freq_filter == "Per Round" else ""

    specs = _details_specs(details)
    if not specs:
        rendered = "" if isinstance(details, (list, dict)) else str(details)
        return rendered if freq_filter == "Per Round" else ""

    needed_triggers = set()
    for spec in specs:
        if not isinstance(spec, dict):
            continue
        if not _spec_in_filter(spec, freq_filter):
            continue
        when = spec.get("when")
        if isinstance(when, dict) and when:
            needed_triggers.update(k for k, t in when.items() if isinstance(t, dict))

    cached = {}
    trigger_values = {}
    if preferred is None:
        for i, spec in enumerate(specs):
            if not needed_triggers:
                break
            if not isinstance(spec, dict) or spec.get("when"):
                continue
            if not _spec_in_filter(spec, freq_filter):
                continue
            name, value = _resolve_field_value(spec, task, master)
            cached[i] = (name, value)
            key = str(spec.get("name") or name)
            if key in needed_triggers:
                trigger_values[key] = value

    parts = []
    for i, spec in enumerate(specs):
        if not isinstance(spec, dict):
            if freq_filter == "Per Round":
                parts.append(str(spec))
            continue

        if not _spec_in_filter(spec, freq_filter):
            continue

        key_name = str(spec.get("name") or "").strip()
        if preferred is not None and key_name in preferred:
            name = key_name
            value = preferred[name]
            sep = spec.get("separator")
        elif spec.get("when"):
            branch = _resolve_when_spec(spec, task, master, trigger_values)
            if branch is None:
                continue
            outer_name = spec.get("name")
            eff = dict(branch) if isinstance(branch, dict) else {"value": branch}
            if outer_name:
                eff["name"] = outer_name
            name, value = _resolve_field_value(eff, task, master)
            sep = eff.get("separator")
        elif i in cached:
            name, value = cached[i]
            sep = spec.get("separator")
        else:
            name, value = _resolve_field_value(spec, task, master)
            sep = spec.get("separator")
        if not name or value is None:
            continue
        if isinstance(value, (list, tuple)):
            formatted = [_fmt_detail_value(v) for v in value]
            if not formatted:
                continue
            rendered = _join_values(formatted, sep)
        else:
            rendered = _fmt_detail_value(value)
        if not rendered:
            continue
        parts.append(f"{name}: {rendered}")

    return ", ".join(parts)


def resolve_details_fields(details, task, master=None, freq_filter=None):
    """Resolve a task's ``details`` spec into ``{field: value}`` pairs.

    Like :func:`resolve_details` but returns the raw resolved values (lists
    stay lists) keyed by field name, so the exact combination can be hashed by
    the theme tracker. A plain string returns ``{}`` (nothing to track).

    ``freq_filter`` restricts to one change-frequency pass (``"Per Round"`` /
    ``"Per Turn"``); ``None`` resolves every field (default).
    """
    if master is None:
        master = MASTER_DETAILS

    if isinstance(details, str):
        return {}

    specs = _details_specs(details)
    if not specs:
        return {}

    needed_triggers = set()
    for spec in specs:
        if not isinstance(spec, dict):
            continue
        if not _spec_in_filter(spec, freq_filter):
            continue
        when = spec.get("when")
        if isinstance(when, dict) and when:
            needed_triggers.update(k for k, t in when.items() if isinstance(t, dict))

    cached = {}
    trigger_values = {}
    for i, spec in enumerate(specs):
        if not needed_triggers:
            break
        if not isinstance(spec, dict) or spec.get("when"):
            continue
        if not _spec_in_filter(spec, freq_filter):
            continue
        name, value = _resolve_field_value(spec, task, master)
        cached[i] = (name, value)
        key = str(spec.get("name") or name)
        if key in needed_triggers:
            trigger_values[key] = value

    fields = {}

    for i, spec in enumerate(specs):
        if not isinstance(spec, dict):
            continue
        if not _spec_in_filter(spec, freq_filter):
            continue
        if spec.get("when"):
            branch = _resolve_when_spec(spec, task, master, trigger_values)
            if branch is None:
                continue
            outer_name = spec.get("name")
            eff = dict(branch) if isinstance(branch, dict) else {"value": branch}
            if outer_name:
                eff["name"] = outer_name
            name, value = _resolve_field_value(eff, task, master)
        elif i in cached:
            name, value = cached[i]
        else:
            name, value = _resolve_field_value(spec, task, master)

        if not name or value is None:
            continue
        if isinstance(value, (list, tuple)):
            formatted = [_fmt_detail_value(v) for v in value]
            value = formatted if formatted else None
        else:
            value = _fmt_detail_value(value)
        if value:
            fields[name] = value
    return fields


def build_combo_dict(genre, mood, persona_details, details_fields):
    """Assemble the tracked combination: detail fields + mood + genre + role + persona."""
    kaya = (persona_details or {}).get("Kaya", {}) or {}
    kolpo = (persona_details or {}).get("Kolpo", {}) or {}
    return {
        "genre": genre or "",
        "mood": mood or "",
        "role": " / ".join(x for x in [kaya.get("role"), kolpo.get("role")] if x),
        "persona": " / ".join(
            x for x in [kaya.get("persona"), kolpo.get("persona")] if x
        ),
        "details": details_fields or {},
    }


def build_theme_slug(task, mood, detail_fields, max_len=80):
    """Build a short, readable theme slug purely from already-resolved data —
    no LLM call needed. detail_fields already carries real variety via its
    roundrobin/random selectors (hero, setting, festival, sweet, mystery,
    trope, etc.); combining the most 'subject-like' ones with the mood gives
    a distinct, human-readable premise for free. combo_hash (the actual
    dedup mechanism used by check_combo_used) never reads this field — it
    only makes the 'Already Produced Themes' prompt block readable for the
    agents, so there is nothing here that needs an LLM to invent.
    """
    preferred_keys = [
        "hero",
        "mystery",
        "trope",
        "sweet",
        "festival",
        "animals",
        "domain",
        "topic",
        "target",
        "setting",
    ]
    parts = []
    for key in preferred_keys:
        val = detail_fields.get(key)
        if not val:
            continue
        parts.append(val if isinstance(val, str) else ", ".join(val))
        if len(parts) >= 2:
            break
    if not parts:
        for v in detail_fields.values():
            parts.append(v if isinstance(v, str) else ", ".join(v))
            if len(parts) >= 2:
                break
    if mood:
        parts.append(mood)
    slug = " · ".join(p for p in parts if p) or task
    return slug[:max_len]


def theme_api(action, token, **payload):
    """Talk to the server's theme tracker (/api/themes). Returns parsed JSON."""
    headers = auth_headers(token)
    try:
        if action == "list":
            r = requests.get(
                f"{BASE_URL}/api/themes", params=payload, headers=headers, timeout=15
            )
            r.raise_for_status()
        else:
            r = requests.post(
                f"{BASE_URL}/api/themes", json=payload, headers=headers, timeout=15
            )
            r.raise_for_status()
        return r.json()
    except Exception as e:
        print(f"[theme] {action} failed: {e}")
        return {"ok": False, "error": str(e)}


def fetch_used_themes(token, scope=SELF_CHAT_THEME_SCOPE):
    """Return the list of already-logged theme records for the window scope."""
    data = theme_api("list", token, scope=scope, limit=SELF_CHAT_THEME_LIMIT)
    return data.get("themes", []) if data.get("ok") else []


def check_combo_used(token, combo, scope=SELF_CHAT_THEME_SCOPE, level="round"):
    data = theme_api(
        "check", token, operation="check", scope=scope, level=level, **combo
    )
    return bool(data.get("used")) if data.get("ok") else False


def format_theme_block(records):
    """Render the shared 'Already Produced Themes' block for the prompt."""
    if not records:
        return "None yet — everything is available."
    lines = []
    for r in records:
        bits = []
        if r.get("genre"):
            bits.append(f"genre: {r['genre']}")
        if r.get("mood"):
            bits.append(f"mood: {r['mood']}")
        if r.get("role"):
            bits.append(f"role: {r['role']}")
        if r.get("persona"):
            bits.append(f"persona: {r['persona']}")
        if r.get("details") and r.get("details") != "{}":
            try:
                det = json.loads(r["details"])
            except (TypeError, ValueError):
                det = {}
            if det:
                bits.append("details: " + ", ".join(f"{k}={v}" for k, v in det.items()))
        if r.get("theme"):
            bits.append(f"theme: {r['theme']}")
        if not bits:
            continue
        status = r.get("status") or ""
        lines.append(f"  - ({status}) " + " | ".join(bits))
    return "\n".join(lines) if lines else "None yet — everything is available."


def load_config_file(tasks_file):
    if not os.path.isfile(tasks_file):
        print(f"Tasks file not found: {tasks_file}")
        return [], {}, {}, {}
    with open(tasks_file, "r", encoding="utf-8") as f:
        try:
            data = json.load(f)
        except json.JSONDecodeError as e:
            print(f"[config] Invalid JSON in {tasks_file}: {e.msg}")
            raise SystemExit(1) from e

    if isinstance(data, dict):
        tasks = _parse_tasks(data.get("tasks") or [])
        checklists = data.get("genre_checklists") or {}
        persona_map = data.get("genre_persona_map") or {}
        persona_pool = data.get("persona_pool") or {}
        master = data.get("master_details") or {}
        if master:
            MASTER_DETAILS.update(master)
        return tasks, checklists, persona_map, persona_pool

    return _parse_tasks(data), {}, {}, {}


def load_tasks():
    checklists = {}

    if args.config:
        persona_map = {}
        persona_pool = {}

        tasks, cfg_checklists, cfg_persona_map, cfg_persona_pool = load_config_file(
            args.config
        )
        source = args.config
        checklists.update(cfg_checklists)

        # Merge config persona overrides
        deep_merge(persona_map, cfg_persona_map)
        deep_merge(persona_pool, cfg_persona_pool)

        if args.defaults:
            defaults, def_checklists, def_pmap, def_ppool = load_config_file(
                DEFAULT_TASKS_FILE
            )
            existing = {t["task"] for t in tasks}
            tasks.extend(t for t in defaults if t["task"] not in existing)
            checklists.update(def_checklists)
            source = f"{args.config} + defaults"
    else:
        persona_map = load_json_file(GENRE_PERSONA_MAP_FILE, {})
        persona_pool = load_json_file(PERSONA_POOL_FILE, {})

        tasks, def_checklists, def_pmap, def_ppool = load_config_file(
            DEFAULT_TASKS_FILE
        )
        checklists.update(def_checklists)
        source = DEFAULT_TASKS_FILE

    return tasks, source, checklists, persona_map, persona_pool


def checklist_for(genre, role, task_checklist=None):
    """role is 'editor' or 'moderator'. A task's own checklist wins, then the
    genre's entry, then the 'default' entry, then nothing."""
    items = (task_checklist or {}).get(role)
    if not items:
        entry = GENRE_CHECKLISTS.get(genre) or {}
        items = entry.get(role)
    if not items:
        items = GENRE_CHECKLISTS.get("default", {}).get(role) or []
    items.append(
        "Remove any out-of-character planning or meta-discussion between the collaborators (e.g. 'let's write about X', 'I'll cover Y, you do Z') that is not part of the narrative/content itself — the final piece must read as continuous, in-universe content only."
    )
    return "\n".join(f"- {item}" for item in items)


# Unicode block ranges used to sanity-check that a story is actually written in
# the language it was assigned, without needing an LLM call to find out.
_SCRIPT_RANGES = {
    "bengali": (0x0980, 0x09FF),
    "hindi": (0x0900, 0x097F),
}


def check_language_script(text, language):
    lang_key = (language or "").strip().lower()
    rng = _SCRIPT_RANGES.get(lang_key)
    if not rng:
        return True  # English or an unmapped language — skip this check
    lo, hi = rng
    body = re.sub(r"(?s)<small.*?</small>", "", text)
    total_letters = sum(1 for ch in body if ch.isalpha())
    if total_letters == 0:
        return False
    script_chars = sum(1 for ch in body if lo <= ord(ch) <= hi)
    return (script_chars / total_letters) > 0.5


def run_dry_run():
    """Print the full plan for every task and validate the environment without
    making any LLM call. Exits before any session is created."""

    def indent(text):
        return "\n".join("    " + line for line in text.splitlines())

    print("=" * 68)
    print(f"DRY RUN — {len(TASKS)} task(s) from {TASKS_SOURCE}")
    print("No LLM calls will be made.\n")

    for idx, spec in enumerate(TASKS, 1):
        task = spec["task"]
        genre = spec.get("genre") or "General"
        mediums = spec.get("mediums") or []
        languages = spec.get("languages") or []
        roles = spec.get("roles") or ["free"]
        details = spec.get("details") or ""
        checklist = spec.get("checklist") or {}
        source = "task" if checklist else "genre/default"
        inactive = spec.get("inactive") or False

        print(f"=== Task {idx}: {task}")
        print(f"  genre:       {genre}   (checklist source: {source})")
        print(f"  path:        {resolve_story_path(spec, roles)}")
        print(f"  inactive:        {inactive}")
        print(f"  languages:   {', '.join(languages)}")
        for lang in languages:
            if (lang or "").strip().lower() in _SCRIPT_RANGES:
                print(
                    f"               - '{lang}' -> script enforcement active (bengali/hindi)"
                )
            else:
                print(f"               - '{lang}' -> no script check (unmapped)")
        for medium in mediums:
            flag = ""
            if medium.strip().lower() == "audio":
                flag = "   WARNING: no audio tool exists — round will be skipped by the guard"
            print(f"  mediums:     {medium}{flag}")
        print(f"  roles:       {', '.join(roles)}")
        print(f"  turns:       {spec.get('turns') or MAX_MESSAGES_PER_AGENT} per agent")
        if spec.get("research"):
            print(
                f"  research:    YES — first {spec.get('research_turns') or 1} turn(s) of each agent are research-only"
            )
        else:
            print("  research:    no — direct content turns from the start")
        if isinstance(details, list):
            names = [
                d.get("name", "?") if isinstance(d, dict) else "?" for d in details
            ]
            print(
                f"  details:     {len(details)} structured field(s) -> {', '.join(str(n) for n in names)}"
            )
        elif isinstance(details, dict):
            print(
                f"  details:     {len(details)} structured field(s) -> {', '.join(details)}"
            )
        else:
            print(
                f"  details:     {'present (' + str(len(details)) + ' chars)' if details else 'EMPTY'}"
            )
        print("  editor checklist (resolved):")
        print(indent(checklist_for(genre, "editor", checklist)))
        print("  moderator checklist (resolved):")
        print(indent(checklist_for(genre, "moderator", checklist)))
        print("ENVIRONMENT")
        print(f"  tasks source:        {TASKS_SOURCE}")
        print(
            f"  persona map:         {len(GENRE_PERSONA_MAP)} genre mapping(s) active"
        )
        print(
            f"  persona pool:        {len(PERSONA_POOL)} relationship category(ies) active"
        )
        print()

    print("=" * 68)
    print("ENVIRONMENT")
    print(f"  tasks source:        {TASKS_SOURCE}")
    print(f"  genre_checklists:    {GENRE_CHECKLISTS_FILE}")
    if not os.path.isfile(GENRE_CHECKLISTS_FILE):
        print("                         MISSING — falling back to empty checklists")
    else:
        print(f"                         loaded ({len(GENRE_CHECKLISTS)} genre(s))")

    handled = {
        SELF_CHAT_PROMPT_FILE: {
            "%task%",
            "%mediums%",
            "%_lang%",
            "%details%",
            "%themes%",
            "%relationship%",
            "%mood%",
            "%kaya_role%",
            "%kaya_persona%",
            "%kolpo_role%",
            "%kolpo_persona%",
        },
        EDITOR_PROMPT_FILE: {
            "%genre%",
            "%mediums%",
            "%language%",
            "%details%",
            "%checklist%",
        },
        MODERATOR_PROMPT_FILE: {
            "%genre%",
            "%mediums%",
            "%language%",
            "%details%",
            "%checklist%",
        },
        CRITIQUE_PROMPT_FILE: {
            "%genre%",
            "%mediums%",
            "%language%",
            "%details%",
            "%checklist%",
            "%cast%",
        },
    }
    for path, placeholders in handled.items():
        name = os.path.basename(path)
        if not os.path.isfile(path):
            print(f"  prompt file {name}: MISSING")
            continue
        with open(path, encoding="utf-8") as f:
            found = set(re.findall(r"%[a-z_]+%", f.read()))
        unhandled = found - placeholders
        if unhandled:
            print(
                f"  prompt file {name}: ok, but UNHANDLED placeholders {sorted(unhandled)}"
            )
        else:
            print(
                f"  prompt file {name}: ok ({len(found)} placeholder(s) replaced by code)"
            )
    print()


_PROHIBITED_NAMES = ("Kaya", "Kolpo", "কায়া", "কল্প", "काया", "कल्प")


def _is_placeholder_query(query):
    """Detect a generic search query that grounds a citation in nothing real.

    Suitable-for/recent/kids/lighthearted phrasing (or a very short query) means
    the model searched for "something" rather than a concrete reported event, so
    the resulting citation is decorative, not grounding.
    """
    q = (query or "").strip()
    if not q or len(q) < 12:
        return True
    generic = [
        r"\brecent\b[^.,]*\bsuitable\s+for\b",
        r"\bsuitable\s+for\b",
        r"\bfor\s+(kids|children)\b",
        r"(latest|top|interesting|random|lighthearted)\s+(news|story|article|event)",
        r"\bnews\b[^.,]*\bfor\b",
    ]
    return any(re.search(p, q, re.I) for p in generic)


def verify_task_fulfillment(
    original_text, check_text, mediums, language, retrieved_citations=None
):
    """Deterministic (no-LLM) checks that catch the failure classes an editor/
    moderator LLM keeps missing: declared medium never delivered, header fields
    dropped during editing, citations dropped or fabricated, ungrounded
    citations, wrong script/language, and agent-name leaks. Returns a list of
    problem strings (empty = all good)."""
    problems = []

    if "audio" in mediums:
        problems.append(
            "Medium 'audio' was declared, but no audio-generation tool exists yet "
            "in TOOLS — this task cannot currently be fulfilled by the agents."
        )

    if "image" in mediums and not re.search(r"!\[[^\]]*\]\([^)]+\)", check_text):
        problems.append(
            "Medium 'image' was declared but no image is embedded in the final story."
        )

    for field in ["Task prompt", "Genre", "Mediums", "Language(s)"]:
        if f"**{field}:**" in original_text and f"**{field}:**" not in check_text:
            problems.append(f"Editor dropped the '{field}' header field.")

    if re.search(
        r"#+\s+Citations?\s*&?\s*References?", original_text
    ) and not re.search(r"#+\s+Citations?\s*&?\s*References?", check_text):
        problems.append("Editor dropped the Citations & References section.")

    if retrieved_citations is not None:
        published = re.findall(r"\[[^\]]*\]\((https?://[^)\s]+)\)", check_text)
        backed = set(retrieved_citations)
        unbacked = [u for u in published if u not in backed]
        if unbacked:
            problems.append(
                f"{len(unbacked)} citation URL(s) in the story were never retrieved by a web search."
            )
        if published and backed:
            queries = {q for _, q in retrieved_citations.values()}
            if queries and all(_is_placeholder_query(q) for q in queries):
                problems.append(
                    "Citations are ungrounded: every search used a generic placeholder "
                    "query instead of sourcing the story from a real reported event."
                )

    if not check_language_script(check_text, language):
        problems.append(
            f"Story does not appear to be predominantly written in the declared language '{language}'."
        )

    stripped = re.sub(r"(?s)<small.*?</small>|!\[[^\]]*\]\([^)]*\)", "", check_text)
    for bad in _PROHIBITED_NAMES:
        if re.search(rf"\b{re.escape(bad)}\b", stripped):
            problems.append(f"Prohibited name '{bad}' still appears in the story text.")

    if "<!-- EDITOR FLAG:" in check_text:
        flags = re.findall(r"<!--\s*EDITOR FLAG:\s*(.*?)-->", check_text)
        for flag in flags:
            problems.append(f"Editor flagged an unresolved problem: {flag.strip()}")

    return problems


def is_duplicate(new_text, previous_text, threshold=0.8):
    if not previous_text:
        return False
    return (
        SequenceMatcher(None, new_text.strip(), previous_text.strip()).ratio()
        > threshold
    )


# Authentik access tokens are short-lived (currently 5 minutes), but agents
# log in once per run and keep working for hours. The cache below keeps ONE
# current token per agent USERNAME: auth_headers() may be handed any stale
# alias of that agent and always resolves it to the latest token, refreshing
# at most once per expiry window. Callers keep their original "fixed" token
# variables for the whole run — healing happens transparently here.
#
# (Keying by token instead — as an earlier revision did — minted a fresh
# password grant on EVERY poll: the healed token was used for one request and
# discarded, so the next poll saw the stale token again and re-authenticated,
# looping indefinitely.)
_TOKEN_META = {}   # username -> {"password", "token", "exp", "granted_at"}
_TOKEN_OWNER = {}  # every issued token -> owning username (stale aliases included)
_TOKEN_LOCK = threading.RLock()
TOKEN_REFRESH_MARGIN = 60


def _decode_exp(token):
    """Expiry timestamp of a JWT access token, or 0 if unreadable."""
    try:
        payload = token.split(".")[1]
        data = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
        return int(data.get("exp", 0))
    except Exception:
        return 0


def login(username, password):
    from server.auth import oidc_password_grant

    try:
        token = oidc_password_grant(username, password)
    except Exception as e:
        print(f"[login] Authentik password grant failed for {username}: {e}")
        raise
    with _TOKEN_LOCK:
        _TOKEN_META[username] = {
            "password": password,
            "token": token,
            "exp": _decode_exp(token),
            "granted_at": time.time(),
        }
        _TOKEN_OWNER[token] = username
    return token


def _fresh_token(token):
    """Return the CURRENT token owned by whoever issued ``token``.

    Re-grants only when that token is at ``TOKEN_REFRESH_MARGIN`` seconds from
    expiry (or when its expiry is unknown and it is older than the margin).
    Unknown tokens pass through untouched.
    """
    with _TOKEN_LOCK:
        username = _TOKEN_OWNER.get(token)
        if username is None:
            # Token we did not issue ourselves — nothing to heal with.
            return token
        entry = _TOKEN_META[username]
        now = time.time()
        exp = entry["exp"]
        expired = (
            (exp and now >= exp - TOKEN_REFRESH_MARGIN)
            or (not exp and now - entry["granted_at"] >= TOKEN_REFRESH_MARGIN)
        )
        if not expired:
            # Resolve stale aliases to the agent's current token without
            # re-authenticating — this is what breaks the reauth loop.
            return entry["token"]
        print(f"[auth] {username}'s access token expired — re-authenticating")
        return login(username, entry["password"])


def auth_headers(token):
    """Headers carrying the Authentik access token as a Bearer credential."""
    with _TOKEN_LOCK:
        token = _fresh_token(token)
    return {"Authorization": f"Bearer {token}"}


def create_session(
    token, name, system_prompts=None, context_tokens=None, system_prompt=None
):
    body = {"name": name}
    if system_prompts:
        body["system_prompts"] = system_prompts
    if context_tokens:
        body["context_tokens"] = context_tokens
    if system_prompt:
        body["system_prompt"] = system_prompt
    resp = requests.post(
        f"{BASE_URL}/api/sessions",
        json=body,
        headers=auth_headers(token),
        timeout=15,
    )
    resp.raise_for_status()
    return resp.json()["session_id"]


def delete_session(token, session_id):
    resp = requests.delete(
        f"{BASE_URL}/api/sessions/{session_id}",
        headers=auth_headers(token),
        timeout=15,
    )
    if resp.status_code != 200:
        print(
            f"Warning: could not delete session {session_id} (HTTP {resp.status_code})"
        )
        return False
    print(f"Deleted session {session_id}")
    return True


def image_url_to_b64(image_url):
    if not image_url:
        return None
    rel_name = image_url.split("/output/")[-1]
    comfy_output = os.path.expanduser("~/local-ai-files/ComfyUI/output")
    abs_path = os.path.join(comfy_output, rel_name)
    if not os.path.isfile(abs_path):
        return None
    with open(abs_path, "rb") as f:
        return base64.b64encode(f.read()).decode()


def register_agent_tokens(tokens, usernames=None):
    try:
        requests.post(
            f"{BASE_URL}/api/register-agent",
            json={"tokens": tokens, "usernames": usernames or []},
            timeout=10,
        )
    except Exception as e:
        print(f"[wait] Could not register agent tokens: {e}")


def active_real_users():
    try:
        r = requests.get(f"{BASE_URL}/api/active-users", timeout=10)
        users = r.json().get("users", [])
    except Exception as e:
        print(f"[wait] Could not check active users: {e}")
        return []
    return users


def wait_for_user_to_leave():
    return


"""
    while True:
        real = active_real_users()
        if not real:
            print("Resuming agent workflow")
            return
        print(
            f"[wait] Real user(s) active ({', '.join(real)}) "
            f"— pausing self-chat until they log out..."
        )
        #time.sleep(POLL_INTERVAL_SECONDS)
"""


def call_llm(
    token, session_id, message, image_b64=None, no_tools=False, research=False
):
    headers = auth_headers(token)

    payload = {
        "session_id": session_id,
        "message": message,
        "client_timestamp": datetime.now().astimezone().isoformat(timespec="seconds"),
    }
    if args.gpu:
        payload["mode"] = "gpu"
    if image_b64:
        payload["image"] = image_b64
    if no_tools:
        payload["no_tools"] = True
    if research:
        payload["research"] = True

    submit_respo = requests.post(
        f"{BASE_URL}/api/chat",
        json=payload,
        headers=headers,
        timeout=30,
    )
    submit_respo.raise_for_status()
    task_id = submit_respo.json()["task_id"]

    status_url = f"{BASE_URL}/api/status/{task_id}"

    while True:
        # Re-resolve headers every poll: a long CPU generation can outlive
        # the token, and each poll must carry a still-valid bearer.
        status_resp = requests.get(
            status_url, headers=auth_headers(token), timeout=40
        )
        status_resp.raise_for_status()
        data = status_resp.json()

        status = data.get("status")

        if status == "done":
            return {
                "text": data["response"],
                "image": data.get("image"),
                "searches": data.get("_search_details"),
            }
        if status == "error":
            raise RuntimeError(f"Task failed: {data}")
        time.sleep(POLL_INTERVAL_SECONDS)


def build_input(
    speaker,
    message_number,
    incoming,
    lang,
    task,
    context=None,
    turns=None,
    per_turn_details="",
    cast=None,
    mode="content",
    research_turns=0,
):
    current_agent = AGENT_NAMES[speaker]
    partner_agent = AGENT_NAMES["B" if speaker == "A" else "A"]

    if turns is None:
        turns = MAX_MESSAGES_PER_AGENT

    lines = [
        f"[SYSTEM DIRECTIVE: You are responding as {current_agent}. Your partner is {partner_agent}.]\n",
        f"[Turn {message_number}/{turns}]\n",
        "[SYSTEM DIRECTIVE: Place ALL meta-analysis, praise, and planning OUTSIDE the [CONTENT] tags. ",
        "The [CONTENT] block must ONLY contain clean narrative/visual deliverable text.]",
    ]

    if cast:
        lines.append(cast)

    if context is not None:
        lines.append(context)

    if mode == "research":
        # Research phase: gather and share sourced material only. Content turns
        # (which follow once BOTH agents have contributed research) write the
        # actual deliverable inside [CONTENT] using the shared materials.
        lines.append(
            "[RESEARCH MODE: This turn is for research ONLY. Perform web searches "
            "and fetch pages to gather sourced facts, figures, and material needed "
            f"for the task. Do NOT write any final story or deliverable content yet, "
            "and do NOT open a [CONTENT] block this turn. Write your findings and "
            "their sources in plain text so your partner can read them, then end "
            f"with [NEXT TURN: {partner_agent}]."
        )
    elif message_number <= 2:
        lines.append(
            f"Immediately establish your role and provide the first creative deliverable inside [CONTENT] tags."
        )
    elif message_number >= turns - 2:
        lines.append(
            f"[PHASE 3: FINALIZATION] Consolidate the work, write the final scene/panels, "
            f"and terminate the turn sequence by appending {STOP_PHRASE}."
        )
    else:
        lines.append(
            f"[PHASE 2: DIRECT EXECUTION] Continue building content turn-by-turn. "
            f"Do not send meta-talk or prematurely end the story. Speak in {lang}."
        )

    if mode == "content" and research_turns and message_number == research_turns + 1:
        lines.append(
            "[CONTENT MODE: Research is complete and all gathered materials are "
            "shared above. Now write the actual deliverable story using those "
            "materials, wrapping every publishable part in [CONTENT]...[/CONTENT]."
        )

    if per_turn_details:
        lines.append(f"[DYNAMIC TURN ATTRIBUTES: {per_turn_details}]")

    if incoming:
        lines.extend(["", "----------", incoming])
    return "\n".join(lines)


def run_single_conversation(
    token_a,
    token_b,
    round_number,
    task,
    mediums,
    languages,
    roles=None,
    genre="General",
    details="",
    details_spec=None,
    checklist=None,
    path=None,
    context=None,
    persona=None,
    themes_context="",
    turns=None,
    task_roles=None,
    round_fields=None,
    per_turn_task=False,
    research=False,
    research_turns=1,
):
    medium = random.sample(mediums, 2 if len(mediums) > 1 else 1)
    language = random.choice(languages)

    # Pick persona tuple. run_forever may pass a pre-picked one so the theme
    # tracker sees the exact same combination the conversation will use.
    if persona is None:
        relationship, mood, persona_details = pick_persona_round_robin(
            PERSONA_POOL, genre, GENRE_PERSONA_MAP, task_roles
        )
    else:
        relationship, mood, persona_details = persona

    persona_info = {
        "relationship": relationship,
        "mood": mood,
        "details": persona_details,
    }

    kaya_info = persona_details.get("Kaya", {})
    kolpo_info = persona_details.get("Kolpo", {})

    print(f"[persona] Genre: {genre} | Dynamic: {relationship} ({mood})")
    print(f"[persona] Kaya: {kaya_info.get('role')} — {kaya_info.get('persona')}")
    print(f"[persona] Kolpo: {kolpo_info.get('role')} — {kolpo_info.get('persona')}")

    # Decide and name the story's characters once per round, before any turn.
    cast_block = format_cast_block(build_cast(task, details_spec, round_fields))
    if cast_block:
        print(f"[cast] {cast_block.splitlines()[1]} ...")

    s = STARTING_CONVERSATION.replace("%task%", task)
    s = s.replace("%mediums%", " , ".join(medium))
    s = s.replace("%_lang%", language)
    s = s.replace("%details%", details or "None")
    s = s.replace("%themes%", themes_context or "None yet — everything is available.")
    s = s.replace("%relationship%", relationship)
    s = s.replace("%mood%", mood)
    s = s.replace("%kaya_role%", kaya_info.get("role", "Partner"))
    s = s.replace("%kaya_persona%", kaya_info.get("persona", "Creative"))
    s = s.replace("%kolpo_role%", kolpo_info.get("role", "Partner"))
    s = s.replace("%kolpo_persona%", kolpo_info.get("persona", "Methodical"))

    print(s)
    session_a = create_session(
        token_a,
        f"{AGENT_NAMES['A']} round {round_number}",
        system_prompt=s,
    )
    session_b = create_session(
        token_b,
        f"{AGENT_NAMES['B']} round {round_number}",
        system_prompt=s,
    )

    transcript = []
    counts = {"A": 0, "B": 0}

    if turns is None:
        turns = MAX_MESSAGES_PER_AGENT

    current_speaker = "A"

    incoming = ""
    shared_image_b64 = None

    stories_dir, fname = start_story(
        round_number, task, task, medium, language, roles, genre, path, persona_info
    )
    citations = {}

    while True:
        counts[current_speaker] += 1
        message_number = counts[current_speaker]
        idx = len(transcript)
        token = token_a if current_speaker == "A" else token_b
        session = session_a if current_speaker == "A" else session_b

        per_turn_str = ""
        turn_theme_id = None
        if per_turn_task:
            # Resolve this turn's per-turn detail fields, re-rolling until the
            # FULL combination (round scope fields + per-turn fields + mood +
            # persona) has not already been produced. Round scope fields were
            # resolved once for the whole round, so the character never changes
            # mid-story.
            turn_fields = {}
            for attempt in range(MAX_THEME_REROLL):
                turn_fields = resolve_details_fields(
                    details_spec, task, MASTER_DETAILS, freq_filter="Per Turn"
                )
                combo = build_combo_dict(
                    genre,
                    mood,
                    persona_details,
                    {**(round_fields or {}), **turn_fields},
                )
                if not check_combo_used(token_a, combo, level="turn"):
                    break
                print(
                    f"[theme] Turn combination already used (attempt {attempt + 1}); "
                    f"re-rolling per-turn details"
                )
            else:
                print(
                    "[theme] Exhausted per-turn re-roll attempts; proceeding with the last combination"
                )
            per_turn_str = resolve_details(
                details_spec,
                task,
                MASTER_DETAILS,
                freq_filter="Per Turn",
                preferred=turn_fields,
            )
            turn_slug = build_theme_slug(
                task, mood, {**(round_fields or {}), **turn_fields}
            )
            logged = theme_api(
                "log",
                token_a,
                operation="log",
                scope=SELF_CHAT_THEME_SCOPE,
                level="turn",
                theme=turn_slug,
                **combo,
            )
            if logged.get("ok"):
                turn_theme_id = (logged.get("theme") or {}).get("id")
                print(f"[theme] Reserved turn combination {turn_theme_id}")
        # Two-phase flow for research tasks: the first research_turns of EACH
        # agent are research-only (gather + share sourced material, no
        # [CONTENT] block), then the agents switch to content mode and write
        # the deliverable using every piece of research that was shared.
        in_research_phase = research and message_number <= research_turns
        mode = "research" if in_research_phase else "content"
        eff_research_turns = research_turns if research else 0
        prompt = build_input(
            current_speaker,
            message_number,
            "" if not transcript else incoming,
            language,
            task,
            context,
            turns,
            per_turn_details=per_turn_str,
            cast=cast_block,
            mode=mode,
            research_turns=eff_research_turns,
        )

        wait_for_user_to_leave()

        result = call_llm(
            token,
            session,
            prompt,
            image_b64=shared_image_b64,
            research=in_research_phase,
        )
        reply = result["text"]
        if not reply.strip():
            prompt += "\n[SYSTEM ERROR: Your previous output was empty. Generate real story content now.]"
            result = call_llm(
                token,
                session,
                prompt,
                image_b64=shared_image_b64,
                research=in_research_phase,
            )
            reply = result["text"]
            if not reply.strip():
                if turn_theme_id:
                    theme_api(
                        "complete",
                        token_a,
                        operation="complete",
                        theme_id=turn_theme_id,
                    )
                    print(f"[theme] Marked turn {turn_theme_id} completed")
                print(
                    f"Round {round_number} ended: {AGENT_NAMES[current_speaker]} "
                    f"returned no content after a retry\n"
                )
                break
        if is_duplicate(reply, incoming):
            # Re-prompt agent to generate new content instead of repeating
            prompt += "\n[SYSTEM ERROR: Your previous output was identical to your partner's. Generate unique content now.]"
            result = call_llm(
                token,
                session,
                prompt,
                image_b64=shared_image_b64,
                research=in_research_phase,
            )
            reply = result["text"]

        if turn_theme_id:
            theme_api("complete", token_a, operation="complete", theme_id=turn_theme_id)
            print(f"[theme] Marked turn {turn_theme_id} completed")

        entry = {
            "speaker": AGENT_NAMES[current_speaker],
            "message": message_number,
            "text": reply,
            "image": result.get("image"),
            "searches": result.get("searches"),
            "publish": not in_research_phase,
        }
        transcript.append(entry)
        append_story_entry(entry, fname, citations, stories_dir, round_number, idx)

        if STOP_PHRASE in reply.upper():
            print(f"Round {round_number} ended by {AGENT_NAMES[current_speaker]}\n")
            break
        if counts[current_speaker] >= turns:
            print(
                f"Round {round_number} ended: {AGENT_NAMES[current_speaker]} "
                f"reached the {turns}-message cap\n"
            )
            break

        incoming = reply
        shared_image = result.get("image")
        if shared_image:
            shared_image_b64 = image_url_to_b64(shared_image)
            incoming += f"\n\n[IMAGE SHARED: {shared_image}]"
        shared_searches = result.get("searches")
        if shared_searches:
            block = []
            for s in shared_searches:
                if not isinstance(s, dict):
                    continue
                query = s.get("query", "")
                block.append(f"- Query: {query}")
                for r in s.get("results") or []:
                    title = r.get("title") or r.get("url") or ""
                    url = r.get("url", "")
                    snippet = (r.get("snippet") or r.get("content") or "")[:200]
                    line = f"  - {title}" + (f" | {snippet}" if snippet else "")
                    if url:
                        line += f" ({url})"
                    block.append(line)
            if block:
                incoming += "\n\n[WEB SEARCH REPORTS SHARED:]\n" + "\n".join(block)
        current_speaker = "B" if current_speaker == "A" else "A"
        print(f"LLM Rest for {SLEEP_BETWEEN_TURNS} seconds")
        time.sleep(SLEEP_BETWEEN_TURNS)
        print("LLM Rest Over")

    finalize_story(fname, stories_dir, citations)

    print("=== Title phase ===")
    with open(fname, "r", encoding="utf-8") as f:
        story_text = f.read()
    title_session, title_token = random.choice(
        [(session_a, token_a), (session_b, token_b)]
    )
    title = propose_title(title_token, title_session, task, language, genre, story_text)
    print(f"Story title: {title}\n")
    stories_dir, fname = apply_title(title, stories_dir, fname)
    print(f"Story renamed to: {fname}\n")

    print("=== Cross-critique phase (Kaya↔Kolpo self-verify) ===")
    edited_path = run_cross_critique(
        stories_dir,
        fname,
        task,
        genre,
        token_a,
        session_a,
        token_b,
        session_b,
        mediums=medium,
        language=language,
        details=details,
        checklist=checklist,
        cast=cast_block,
        citations=citations,
    )

    print("=== Deterministic verification ===")
    with open(fname, "r", encoding="utf-8") as f:
        original_text = f.read()
    check_source = edited_path if edited_path else fname
    with open(check_source, "r", encoding="utf-8") as f:
        check_text = f.read()
    problems = verify_task_fulfillment(
        original_text, check_text, medium, language, citations
    )

    if problems:
        print(
            f"[verify] {len(problems)} problem(s) found — auto-RED, skipping moderator LLM call:"
        )
        for p in problems:
            print(f"[verify]   - {p}")
        verdict_path = (edited_path if edited_path else fname).replace(
            ".md", ".moderation.json"
        )
        data = {
            "verdict": "RED",
            "reasons": "Automatic RED (deterministic check, no LLM call):\n"
            + "\n".join(f"- {p}" for p in problems),
            "task": task,
            "genre": genre,
            "timestamp": datetime.now().astimezone().isoformat(timespec="seconds"),
        }
        with open(verdict_path, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2)
    else:
        print("=== Moderator phase ===")
        print("Moderator Phase Skipped, not much value add")
        # run_moderator(stories_dir, fname, task, genre, editor_path=edited_path, mediums=medium, language=language, details=details, checklist=checklist)

    return transcript, session_a, session_b, fname


def save_transcript(transcript, round_number):
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    fname = f"conv_r{round_number}_{timestamp}.json"
    with open(fname, "w", encoding="utf-8") as f:
        json.dump(transcript, f, indent=4)
    print(f"Saved transcript to {fname}")
    return fname


def slugify(text, max_len=60):
    slug = re.sub(r'[\\/:*?"<>|\x00-\x1f]+', "", text, flags=re.UNICODE)
    slug = re.sub(r"\s+", "-", slug).strip("-")
    return slug[:max_len].strip("-") or "story"


def sanitize_title(text):
    """Extract a clean single-line title from an LLM reply, or None."""
    if not text:
        return None
    first = text.strip().strip("\"'«»“”‘’`").splitlines()[0].strip()
    first = re.sub(
        r"^(?:title|heading|name|header)\s*[::]\s*", "", first, flags=re.IGNORECASE
    )
    first = re.sub(r"^\d+[.)]\s*", "", first)
    first = re.sub(r"\s+", " ", first).strip().rstrip(".।!")
    return first[:80].strip() or None


def propose_title(token, session_id, task, language, genre, story_text):
    """Ask one of the agents to name the story after it is completed."""
    prompt = (
        "You are naming a completed article/story that has reached its "
        "conclusion.\n"
        f"Task: {task}\n"
        f"Genre: {genre}\n"
        f"Write your reply ONLY in: {language}\n"
        "Read the completed story below, then propose ONE short, catchy, unique "
        "title (max 60 characters) that captures its conclusion and theme. "
        "Output ONLY the title text — no quotes, no numbering, no explanation, "
        "no names of people or agents.\n\n"
        "=== COMPLETED STORY ===\n\n" + (story_text or "")[:6000]
    )
    wait_for_user_to_leave()
    try:
        result = call_llm(token, session_id, prompt)
        return sanitize_title(result["text"]) or task
    except Exception as e:
        print(f"[title] Could not generate a title, falling back to task: {e}")
        return task


def apply_title(title, stories_dir, fname):
    """Update the story heading with the new title and rename the folder to match."""
    with open(fname, "r", encoding="utf-8") as f:
        lines = f.readlines()
    new_heading = f"# {title}\n"
    if lines and lines[0].startswith("# "):
        lines[0] = new_heading
    else:
        lines.insert(0, new_heading)
    with open(fname, "w", encoding="utf-8") as f:
        f.writelines(lines)

    m = re.search(r"_\d{8}_\d{6}\.md$", fname)
    if not m:
        return stories_dir, fname
    timestamp = m.group(0).lstrip("_").replace(".md", "")
    genre_dir = os.path.dirname(stories_dir)
    new_stories_dir = os.path.join(genre_dir, f"{slugify(title)}_{timestamp}")
    if new_stories_dir != stories_dir and os.path.isdir(stories_dir):
        os.rename(stories_dir, new_stories_dir)
        stories_dir = new_stories_dir
        fname = os.path.join(new_stories_dir, os.path.basename(fname))
    return stories_dir, fname


def resolve_story_path(spec, roles):
    path = spec.get("path")
    if path:
        if "admin" in roles:
            path = f"{path}/admin"
        if "premium" in roles:
            path = f"{path}/premium"
        return path

    if "admin" in roles:
        if not ADMIN_STORIES_DIR:
            raise ValueError("STORIES_ADMIN_DIR environment variable is not set!")
        return ADMIN_STORIES_DIR

    if "premium" in roles:
        if not PREMIUM_STORIES_DIR:
            raise ValueError("STORIES_PREMIUM_DIR environment variable is not set!")
        return PREMIUM_STORIES_DIR

    return STORY_BASE_DIR


def start_story(
    round_number,
    task,
    title,
    mediums,
    language,
    roles=None,
    genre="General",
    path=None,
    persona_info=None,
):
    base_dir = path or STORY_BASE_DIR
    os.makedirs(base_dir, exist_ok=True)
    now = datetime.now()
    timestamp = now.strftime("%Y%m%d_%H%M%S")
    folder_name = f"{slugify(title)}_{timestamp}"
    genre_dir = os.path.join(base_dir, slugify(genre))
    os.makedirs(genre_dir, exist_ok=True)
    stories_dir = os.path.join(genre_dir, folder_name)
    os.makedirs(stories_dir, exist_ok=True)
    fname = os.path.join(stories_dir, f"story_r{round_number}_{timestamp}.md")
    roles = roles or ["free"]
    rel = persona_info.get("relationship", "N/A") if persona_info else "N/A"
    mood = persona_info.get("mood", "N/A") if persona_info else "N/A"

    header = [
        f"# {title}\n",
        f"*Round {round_number} · Generated on {now.strftime('%Y-%m-%d %H:%M:%S')}*\n\n",
        f"**Task prompt:** {task}\n\n",
        f"**Genre:** {genre}  ·  **Dynamic:** {rel} ({mood})\n\n",
        f"**For roles:** {' , '.join(roles)}\n\n",
        f"**Mediums:** {' , '.join(mediums)}  ·  **Language(s):** {language}\n\n",
        "---\n\n",
    ]
    with open(fname, "w", encoding="utf-8") as f:
        f.writelines(header)
    return stories_dir, fname


def clean_speaker_text(speaker, text):
    cleaned = re.sub(rf"^(kolpo|kaya|कल्प|কায়া):\s*", "", text, flags=re.IGNORECASE)
    cleaned = re.sub(rf"^{re.escape(speaker)}:\s*", "", cleaned, flags=re.IGNORECASE)
    cleaned = re.sub(r"\[NEXT TURN:\s*[^\]]*\]\s*", "", cleaned, flags=re.IGNORECASE)

    # Remove raw action tags automatically
    cleaned = re.sub(r"\[ACTION:\s*[^\]]+\]", "", cleaned, flags=re.IGNORECASE)

    return cleaned.replace("[END CONVERSATION]", "").strip()


def strip_image_markers(text):
    """Drop model-authored image placeholder/reference marker lines.

    The agents occasionally write standalone notes such as ``**(Image
    Reference: the squid from the last turn)**`` or ``**(Image Placeholder:
    /output/...png)**`` instead of (or alongside) a real ``![...](...)``
    embed. These add nothing to the published story, so the whole line is
    removed. Only lines that ARE the marker are stripped — real image embeds
    (`![alt](file)`) and surrounding prose are left untouched.
    """
    pattern = re.compile(
        r"(?im)^\s*\*{0,2}\s*[\[\(]\s*image\s+(?:reference|placeholder|ref|ph)"
        r"\s*:[^\]\)]*[\]\)]\s*\*{0,2}\s*$"
    )
    stripped = pattern.sub("", text or "")
    return re.sub(r"\n{3,}", "\n\n", stripped).strip()


def scrub_agent_names(text):
    """Deterministically remove the agents' names from story content.

    The models often address each other by name despite the naming rules, so the
    names are scrubbed here (vocative positions first, bare mentions as fallback).
    Structural markup is protected: image alt-text (`![Kaya](...)`) and the turn
    headers (`<small ...>_Round N · Kaya Turn M_</small>`) keep their names.
    """
    protected = []
    for token in re.findall(r"(?s)<small.*?</small>|!\[[^\]]*\]\([^)]*\)", text):
        if token not in protected:
            protected.append(token)

    for i, token in enumerate(protected):
        text = text.replace(token, f"\x00PROTECT{i}\x00")

    # Remove names in vocative or isolated positions across supported scripts
    text = re.sub(r"\b(?:Kaya|Kolpo)\b\s*[,،]+\s*", "", text, flags=re.IGNORECASE)
    text = re.sub(r",\s*\b(?:Kaya|Kolpo)\b", "", text, flags=re.IGNORECASE)
    text = re.sub(r"(?:কায়া|কল্প|काया|कल्प)\s*[,،]+\s*", "", text)
    text = re.sub(r",\s*(?:কায়া|কল্প|काया|कल्प)", "", text)
    text = re.sub(r"\b(?:Kaya|Kolpo)\b", "", text, flags=re.IGNORECASE)

    # Normalize inline space without merging lines across newlines
    lines = text.split("\n")
    cleaned_lines = []
    for line in lines:
        line = re.sub(r"[ \t]{2,}", " ", line)
        line = re.sub(r"[ \t]+([.,!?;:।])", r"\1", line)
        cleaned_lines.append(line.replace(" ,", ","))

    text = "\n".join(cleaned_lines)

    for i, token in enumerate(protected):
        text = text.replace(f"\x00PROTECT{i}\x00", token)

    return text.strip()


def normalize_markdown_lines(text):
    """Restore structural line breaks that the editor model may have flattened.

    The editor sometimes returns the revised markdown with most newlines collapsed
    to spaces. This re-inserts line breaks before every structural marker so the
    published page renders as separate blocks again. Within-turn paragraph breaks
    that were already lost cannot be recovered, but every heading, turn header,
    image, and citation line ends up on its own line.
    """
    # Clean horizontal whitespace per line while preserving existing explicit newlines
    lines = [re.sub(r"[ \t]+", " ", line).strip() for line in text.splitlines()]
    text = "\n".join(lines)

    # Re-insert structural double newlines for proper block rendering
    text = re.sub(r"(?<!\n\n)<small ", "\n\n<small ", text)
    text = re.sub(r"(?<!\n\n)(#{1,6}\s)", r"\n\n\1", text)
    text = re.sub(
        r"(?<!\n\n)(\*\*(?:Task prompt|Genre|For roles|Mediums|Language\(s\)):)",
        r"\n\n\1",
        text,
    )
    text = re.sub(r"(?<!\n\n)(\d+\.\s+\[)", r"\n\n\1", text)
    text = re.sub(r"(?<!\n\n)!\[", "\n\n![", text)
    text = re.sub(r"</small>(?!\n\n)", "</small>\n\n", text)
    text = re.sub(r"\s*---\s*", "\n\n---\n\n", text)

    # Clean up excessive line padding
    text = re.sub(r"(?m)^ +", "", text)
    text = re.sub(r"\n{3,}", "\n\n", text)

    return text.strip() + "\n"


def embed_story_image(img_url, stories_dir, round_number, speaker, idx):
    if not img_url:
        return None
    rel_name = img_url.split("/output/")[-1]
    comfy_output = os.path.expanduser("~/local-ai-files/ComfyUI/output")
    abs_src = os.path.join(comfy_output, rel_name)
    if not os.path.isfile(abs_src):
        return None
    _, ext = os.path.splitext(rel_name)
    local_name = f"img_r{round_number}_{speaker}_{idx}{ext}"
    dest = os.path.join(stories_dir, local_name)
    try:
        shutil.copy(abs_src, dest)
    except OSError as e:
        print(f"Warning: could not copy {abs_src}: {e}")
        return None
    print(f"Embedded image {local_name}")
    return local_name


def collect_citations(citations, searches):
    for s in searches or []:
        if not isinstance(s, dict):
            continue
        query = s.get("query", "")
        for r in s.get("results") or []:
            url = r.get("url", "")
            if not url or url in citations:
                continue
            citations[url] = (r.get("title") or url, query)


def strip_model_citations(text):
    """Remove any Citations & References block the model wrote into a turn.

    Only finalize_story() may emit that section, and it is built exclusively from
    web-search results. Model-authored variants (images, placeholder links, double
    hashes) are dropped so they can never leak into the published section.
    """
    text = re.sub(
        r"(?i)(?:^|\n)\s*#{1,6}\s+citations?\s*&?\s*references?.*$",
        "",
        text,
        flags=re.DOTALL,
    )
    return text.strip()


def extract_tagged_content(text):
    """Return only the text inside [CONTENT] blocks.

    The closing tag may appear as ``[/CONTENT]`` (as instructed in the system
    prompt) or as the ``[END CONTENT]`` variant the models actually emit; both
    are accepted, so a turn's narrative is never mistaken for planning chatter.
    Returns None if no [CONTENT] block is present at all — the caller treats
    that as a planning-only turn with nothing to publish. Multiple blocks are
    concatenated in order.

    As a defensive fallback, a message that OPENS with ``[CONTENT]`` but is
    truncated or never closes the tag (the research mode used to provoke this)
    still yields its narrative: everything from the ``[CONTENT]`` marker up to
    the next structural tag (``[NEXT TURN:`` / ``[END CONVERSATION]`` /
    ``[IMAGE GENERATION CALL:]``) or the end of the message."""
    blocks = re.findall(
        r"\[CONTENT\](.*?)(?:\[/CONTENT\]|\[END CONTENT\]|\[END\]|$)",
        text,
        flags=re.DOTALL | re.IGNORECASE,
    )
    if not blocks:
        # Fallback for an unclosed [CONTENT] block. Only trigger when the
        # message clearly starts with the marker, so a 0-block Phase-1 planning
        # turn (no [CONTENT] at all) is still treated as nothing to publish.
        m = re.match(r"(?is)\s*\[CONTENT\]\s*(.*)$", text)
        if m:
            rest = m.group(1)
            rest = re.split(
                r"(?is)\s*\[(?:NEXT TURN\s*:|END CONVERSATION\]|IMAGE GENERATION CALL\s*:|THEME LOGGED\s*:|IMAGE SHARED\s*:)",
                rest,
                maxsplit=1,
            )[0]
            rest = rest.strip()
            blocks = [rest] if rest else []
    if not blocks:
        return None
    return "\n\n".join(b.strip() for b in blocks if b.strip())


def append_story_entry(entry, fname, citations, stories_dir, round_number, idx):
    speaker = entry.get("speaker", "Unknown")
    raw_text = entry.get("text", "")
    turn = entry.get("message", idx)

    # Research-phase turns gather and share material; they must never publish
    # narrative or images, but their web-search results still feed citations.
    if entry.get("publish") is False:
        collect_citations(citations, entry.get("searches"))
        print(
            f"[content] {speaker} turn {turn} — research phase, citations captured only"
        )
        return

    content = extract_tagged_content(raw_text)
    if content is None:
        # No [CONTENT] block — Phase 1 planning turn (or a turn that only
        # ran tools). Still capture any citations. If the turn generated an
        # image, embed it anyway so a generated image is never lost from the
        # story just because the model skipped the [CONTENT] wrapper.
        collect_citations(citations, entry.get("searches"))
        local_img = embed_story_image(
            entry.get("image"), stories_dir, round_number, speaker, idx
        )
        if not local_img:
            print(
                f"[content] No [CONTENT] block in {speaker} turn {turn} — skipping (planning-only)"
            )
            return
        print(
            f"[content] No [CONTENT] block in {speaker} turn {turn} — embedding generated image only"
        )
        lines = [
            f'<small style="color:#888">_Round {round_number} · {speaker} Turn {turn}_</small>\n\n',
            f"![{speaker}]({local_img})\n\n",
        ]
        with open(fname, "a", encoding="utf-8") as f:
            f.writelines(lines)
        return

    cleaned = clean_speaker_text(speaker, content)
    cleaned = scrub_agent_names(cleaned)
    cleaned = strip_model_citations(cleaned)
    cleaned = strip_image_markers(cleaned)
    lines = [
        f'<small style="color:#888">_Round {round_number} · {speaker} Turn {turn}_</small>\n\n',
        f"{cleaned}\n\n",
    ]

    collect_citations(citations, entry.get("searches"))

    local_img = embed_story_image(
        entry.get("image"), stories_dir, round_number, speaker, idx
    )
    if local_img:
        lines.append(f"![{speaker}]({local_img})\n\n")

    with open(fname, "a", encoding="utf-8") as f:
        f.writelines(lines)


def finalize_story(fname, stories_dir, citations):
    sanitize_story_file(fname, stories_dir)
    if citations:
        lines = ["\n---\n\n## Citations & References\n\n"]
        for num, (url, (title, query)) in enumerate(citations.items(), start=1):
            lines.append(
                f"{num}. [{title}]({url})"
                + (f" *(source: {query})*" if query else "")
                + "\n"
            )
        with open(fname, "a", encoding="utf-8") as f:
            f.writelines(lines)
    print(f"Saved story to {fname}")


def file_to_b64(path):
    with open(path, "rb") as f:
        return base64.b64encode(f.read()).decode()


def story_images_in_order(stories_dir, markdown_text):
    """Return [(filename, abs_path)] of images referenced in the markdown, in order."""
    ordered = []
    seen = set()
    for ref in re.findall(r"!\[[^\]]*\]\(([^)]+)\)", markdown_text):
        fname = os.path.basename(ref)
        if fname in seen:
            continue
        seen.add(fname)
        full = os.path.join(stories_dir, fname)
        if os.path.isfile(full):
            ordered.append((fname, full))
    return ordered


def sanitize_story_images(text, stories_dir):
    """Drop markdown image references whose target file does not exist.

    The story agents and the editor sometimes emit ``![...](path)`` lines that
    point at hallucinated filenames or ComfyUI ``/output/`` URLs that were never
    copied into the story folder (real embedded copies always use the
    ``img_rN_Speaker_idx.ext`` scheme). Removing those references keeps the
    published markdown free of dead/broken image tags.
    """

    def _fix(match):
        ref = match.group(1)
        fname = os.path.basename(ref.split("?")[0].split("#")[0])
        if not fname:
            return match.group(0)
        if os.path.isfile(os.path.join(stories_dir, fname)):
            return match.group(0)
        print(f"[images] Dropping broken image reference: {ref}")
        return ""

    return re.sub(r"!\[[^\]]*\]\(([^)]*)\)", _fix, text)


_IMAGE_LINE_RE = re.compile(r"^\s*!\[[^\]]*\]\(([^)]+)\)\s*$")


def _image_anchors(markdown_text):
    """Map each image filename to the narrative paragraph right before it.

    Story files store one turn per entry as ``<small> label, paragraph(s),
    image``; the paragraph adjacent to an image is the scene that image
    illustrates, which is what re-anchoring matches against when the editor
    regroups image references.
    """
    anchors = {}
    pending = []
    for line in markdown_text.splitlines():
        if _IMAGE_LINE_RE.match(line):
            anchor = ""
            for block in reversed(pending):
                if "".join(block).strip():
                    anchor = "\n".join(block)
                    break
            for ref in re.findall(r"!\[[^\]]*\]\(([^)]+)\)", line):
                fn = os.path.basename(ref)
                anchors[fn] = anchor
            pending = []
            continue
        if line.strip().startswith("<small") and line.strip().endswith("</small>"):
            pending = []
            continue
        if not line.strip():
            pending.append([])
            continue
        if pending:
            pending[-1].append(line)
        else:
            pending.append([line])
    return anchors


def _paragraph_overlap_score(a, b):
    """Token Jaccard overlap between two text blocks (0..1)."""

    def _toks(s):
        return set(re.findall(r"[a-z0-9]+", s.lower()))

    left, right = _toks(a), _toks(b)
    if not left or not right:
        return 0.0
    return len(left & right) / len(left | right)


def reanchor_story_images(revised, original, stories_dir):
    """Keep every image embedded inline, right after the narrative it illustrates.

    The editor is free to polish prose but sometimes regroups all image
    references at the top or bottom of the story. This re-anchors each image (in
    the order it appeared in the original story) to the revised paragraph whose
    wording most resembles the turn text the image originally followed, so
    images reliably stay embedded inside the story flow.
    """
    ordered = [fn for fn, _ in story_images_in_order(stories_dir, original)]
    if not ordered:
        return revised
    anchors = _image_anchors(original)
    if not anchors:
        return revised

    lines = revised.splitlines()
    ref_by_fn = {}
    for line in lines:
        if _IMAGE_LINE_RE.match(line):
            ref = re.search(r"!\[[^\]]*\]\(([^)]+)\)", line).group(1)
            ref_by_fn.setdefault(
                os.path.basename(ref).split("?")[0].split("#")[0], line
            )
    missing = [fn for fn in ordered if fn not in ref_by_fn]
    if missing:
        print(
            f"[images] Re-anchor: {len(missing)} reference(s) missing from revision: {missing}"
        )
        return revised

    blocks = []
    cur = []
    for line in lines:
        if _IMAGE_LINE_RE.match(line) or not line.strip():
            if cur:
                blocks.append(cur)
                cur = []
            continue
        cur.append(line)
    if cur:
        blocks.append(cur)
    block_texts = ["\n".join(b).strip() for b in blocks]

    attach = []
    prev = 0
    for fn in ordered:
        anchor = anchors.get(fn, "")
        best, score = -1, -1.0
        for i in range(prev, len(block_texts)):
            s = _paragraph_overlap_score(block_texts[i], anchor)
            if s > score:
                score, best = s, i
        if best < 0:
            best = min(prev, len(block_texts) - 1)
        attach.append(best)
        prev = best + 1

    out = []
    for i, block in enumerate(blocks):
        out.extend(block)
        for k, fn in enumerate(ordered):
            if attach[k] == i:
                out.append("")
                out.append(ref_by_fn[fn])
        out.append("")
    return "\n".join(out).strip() + "\n"


def sanitize_story_file(fname, stories_dir):
    """Rewrite a story markdown file in place, dropping broken image references.

    Used defensively after the round and after the editor phase so a dead image
    tag can never reach the hosted page."""
    try:
        with open(fname, "r", encoding="utf-8") as f:
            text = f.read()
    except OSError as e:
        print(f"[images] Could not read {fname}: {e}")
        return
    cleaned = sanitize_story_images(text, stories_dir)
    if cleaned != text:
        with open(fname, "w", encoding="utf-8") as f:
            f.write(cleaned)
        print(f"[images] sanitized {os.path.basename(fname)}")


def extract_markdown_fence(text):
    match = re.search(
        r"```(?:markdown|md)?\s*(.*?)```", text, flags=re.DOTALL | re.IGNORECASE
    )
    if match:
        return match.group(1).strip()
    return text.strip()


def run_cross_critique(
    stories_dir,
    fname,
    task,
    genre,
    token_a,
    session_a,
    token_b,
    session_b,
    mediums=None,
    language="",
    details="",
    checklist=None,
    cast="",
    citations=None,
    retries=MAX_CRITIQUE_RETRIES,
):
    """Kaya↔Kolpo cross-critique of the finished story (research-style self-verify).

    The deterministic gate (verify_task_fulfillment) runs on the authored story
    first — the cheap, no-LLM check. When it is clean, the slow LLM re-write is
    skipped entirely and the story the two agents wrote is kept as-is. When
    violations exist, the two agents become the verifiers: each retry has one of
    them (rotating Kaya/Kolpo) review their partner's copy, name the exact spot
    of every violation, and return a corrected markdown where ONLY those spots
    changed. The gate re-runs after every attempt; residual problems after
    ``retries`` attempts surface as an auto-RED (never a silently shipped story).

    Returns the path to the ``.edited.md`` file, or ``None`` to keep the original.
    """
    try:
        with open(fname, "r", encoding="utf-8") as f:
            text = f.read()
    except OSError as e:
        print(f"[critique] Could not read story {fname}: {e}")
        return None

    def _gate(check_text):
        return verify_task_fulfillment(
            text, check_text, mediums, language, retrieved_citations=citations
        )

    problems = _gate(text)
    if not problems:
        print("[critique] PASS — no deterministic violations, skipping LLM rewrite")
        return None

    try:
        prompt = open(CRITIQUE_PROMPT_FILE, encoding="utf-8").read()
        context_tokens = {
            "%genre%": genre,
            "%mediums%": ", ".join(mediums or []),
            "%language%": language or "",
            "%details%": details or "None",
            "%checklist%": checklist_for(genre, "editor", checklist),
            "%cast%": cast or "None",
        }
        for placeholder, value in context_tokens.items():
            prompt = prompt.replace(placeholder, value)
    except OSError as e:
        print(f"[critique] Could not read prompt file: {e}")
        return None

    edited_path = fname.replace(".md", ".edited.md")
    partners = [
        (AGENT_NAMES["A"], token_a),
        (AGENT_NAMES["B"], token_b),
    ]
    for attempt in range(max(1, retries)):
        name, token = partners[attempt % len(partners)]
        print(
            f"[critique] Attempt {attempt + 1}/{max(1, retries)} by {name}: "
            f"{len(problems)} residual violation(s)"
        )
        for p in problems:
            print(f"[critique]   - {p}")
        try:
            session_id = create_session(
                token, f"Cross-critique attempt {attempt + 1}", system_prompt=prompt
            )
        except Exception as e:
            print(f"[critique] {name} could not start a critique session: {e}")
            break
        try:
            wait_for_user_to_leave()
            result = call_llm(
                token,
                session_id,
                "Here is the complete story markdown:\n\n"
                + text
                + "\n\nVerification violations to resolve:\n"
                + "\n".join(f"- {p}" for p in problems)
                + "\n\nFollow your WORK MODE exactly: quote each violation's spot, "
                "then return your CRITIQUE comment and the complete corrected "
                "markdown in a single ```markdown code block, changing only the "
                "flagged spots.",
                no_tools=True,
            )
            revised = extract_markdown_fence(result["text"])
            if revised:
                revised = scrub_agent_names(revised)
                revised = normalize_markdown_lines(revised)
                revised = sanitize_story_images(revised, stories_dir)
                revised = strip_image_markers(revised)
                revised = reanchor_story_images(revised, text, stories_dir)
            if not revised:
                print(f"[critique] {name} returned no markdown; keeping original")
                continue
            residual = _gate(revised)
            if not residual:
                with open(edited_path, "w", encoding="utf-8") as f:
                    f.write(revised + "\n")
                print(f"[critique] PASS after {name}'s retry — saved {edited_path}")
                return edited_path
            text = revised
            problems = residual
        except Exception as e:
            print(f"[critique] Attempt {attempt + 1} failed: {e}")
        finally:
            if not keep_sessions:
                delete_session(token, session_id)

    print(f"[critique] FAIL after retries — {len(problems)} residual violation(s):")
    for p in problems:
        print(f"[critique]   - {p}")
    return None


def run_editor(
    stories_dir,
    fname,
    task,
    genre,
    mediums=None,
    language="",
    details="",
    checklist=None,
    cast="",
):
    """Editor phase: review images + markdown, write story_rN_ts.edited.md."""
    try:
        token = login(USERNAME_EDITOR, PASSWORD_EDITOR)
    except Exception as e:
        print(f"[editor] Could not log in {USERNAME_EDITOR}: {e}")
        return None
    register_agent_tokens([token], [USERNAME_EDITOR])
    try:
        prompt = open(EDITOR_PROMPT_FILE, encoding="utf-8").read()
        context_tokens = {
            "%genre%": genre,
            "%mediums%": ", ".join(mediums or []),
            "%language%": language or "",
            "%details%": details or "None",
            "%cast%": cast or "None",
            "%checklist%": checklist_for(genre, "editor", checklist),
        }
        for placeholder, value in context_tokens.items():
            prompt = prompt.replace(placeholder, value)
    except OSError as e:
        print(f"[editor] Could not read prompt file: {e}")
        return None
    session_id = create_session(
        token,
        "Editor review",
        system_prompt=prompt,
        context_tokens=context_tokens,
    )
    edited_path = fname.replace(".md", ".edited.md")
    try:
        with open(fname, "r", encoding="utf-8") as f:
            markdown_text = f.read()
        for img_fname, full in story_images_in_order(stories_dir, markdown_text):
            wait_for_user_to_leave()
            call_llm(
                token,
                session_id,
                f"This is the image referenced in the story as {img_fname}. "
                "Look at it carefully; it is part of the story. Decide the quality of image."
                "If it does not match the task expectation, flag it."
                "But never add new image or edit existing one",
                image_b64=file_to_b64(full),
            )
        wait_for_user_to_leave()
        result = call_llm(
            token,
            session_id,
            "Here is the full story markdown:\n\n"
            + markdown_text
            + "\n\nNow return the complete revised markdown, wrapped in a "
            "single ```markdown code block. Nothing else.",
        )
        revised = extract_markdown_fence(result["text"])
        revised = scrub_agent_names(revised) if revised else revised
        revised = normalize_markdown_lines(revised) if revised else revised
        if not revised:
            print("[editor] Editor returned an empty revision; keeping original")
            return None
        revised = sanitize_story_images(revised, stories_dir)
        revised = strip_image_markers(revised) if revised else revised
        revised = reanchor_story_images(revised, markdown_text, stories_dir)
        with open(edited_path, "w", encoding="utf-8") as f:
            f.write(revised + "\n")
        print(f"[editor] Saved edited story to {edited_path}")
        return edited_path
    except Exception as e:
        print(f"[editor] Editor phase failed: {e}")
        return None
    finally:
        if not keep_sessions:
            delete_session(token, session_id)


def run_moderator(
    stories_dir,
    fname,
    task,
    genre,
    editor_path=None,
    mediums=None,
    language="",
    details="",
    checklist=None,
    cast="",
):
    """Moderator phase: GREEN/RED verdict, written to story_rN_ts.moderation.json."""
    try:
        token = login(USERNAME_MODERATOR, PASSWORD_MODERATOR)
    except Exception as e:
        print(f"[moderator] Could not log in {USERNAME_MODERATOR}: {e}")
        return None
    register_agent_tokens([token], [USERNAME_MODERATOR])
    try:
        prompt = open(MODERATOR_PROMPT_FILE, encoding="utf-8").read()
        context_tokens = {
            "%genre%": genre,
            "%mediums%": ", ".join(mediums or []),
            "%language%": language or "",
            "%details%": details or "None",
            "%cast%": cast or "None",
            "%checklist%": checklist_for(genre, "moderator", checklist),
        }
        for placeholder, value in context_tokens.items():
            prompt = prompt.replace(placeholder, value)
    except OSError as e:
        print(f"[moderator] Could not read prompt file: {e}")
        return None
    session_id = create_session(
        token,
        "Moderator review",
        system_prompt=prompt,
        context_tokens=context_tokens,
    )
    try:
        source = editor_path if editor_path else fname
        with open(source, "r", encoding="utf-8") as f:
            markdown_text = f.read()
        markdown_text = sanitize_story_images(markdown_text, stories_dir)
        for img_fname, full in story_images_in_order(stories_dir, markdown_text):
            wait_for_user_to_leave()
            call_llm(
                token,
                session_id,
                f"This is the image referenced in the story as {img_fname}.",
                image_b64=file_to_b64(full),
            )
        wait_for_user_to_leave()
        result = call_llm(
            token,
            session_id,
            "Here is the final story markdown:\n\n"
            + markdown_text
            + "\n\nGive your verdict using exactly these two lines:\n"
            "VERDICT: GREEN\nREASONS: <short reasons>",
        )
        verdict = "UNKNOWN"
        m = re.search(r"VERDICT\s*:\s*(GREEN|RED)", result["text"], flags=re.IGNORECASE)
        if m:
            verdict = m.group(1).upper()
        elif re.search(r"\bGREEN\b", result["text"]):
            verdict = "GREEN"
        elif re.search(r"\bRED\b", result["text"]):
            verdict = "RED"
        verdict_path = fname.replace(".md", ".moderation.json")
        data = {
            "verdict": verdict,
            "reasons": result["text"],
            "task": task,
            "genre": genre,
            "timestamp": datetime.now().astimezone().isoformat(timespec="seconds"),
        }
        with open(verdict_path, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2)
        print(f"[moderator] Verdict {verdict} saved to {verdict_path}")
        return data
    except Exception as e:
        print(f"[moderator] Moderator phase failed: {e}")
        return None
    finally:
        if not keep_sessions:
            delete_session(token, session_id)


def run_forever():
    token_a = login(USERNAME_A, PASSWORD_A)
    token_b = login(USERNAME_B, PASSWORD_B)
    register_agent_tokens([token_a, token_b], [USERNAME_A, USERNAME_B])
    print("Logged In")

    round_number = 1
    task_index = 0

    try:
        while True:
            spec = TASKS[task_index % len(TASKS)]
            task = spec["task"]
            mediums = spec["mediums"]
            languages = spec["languages"]
            roles = spec.get("roles") or ["free"]
            genre = spec.get("genre") or "General"
            details_spec = spec.get("details") or ""
            checklist = spec.get("checklist") or {}
            path = resolve_story_path(spec, roles)
            context = spec.get("context") or None
            print(roles)
            print("The stories will be generated in this directory", path)
            inactive = spec.get("inactive") or False
            if inactive:
                print(f"Task {task} is inactive, skipping the task")
                task_index += 1
                continue

            if "audio" in mediums:
                print(
                    f"[guard] Task declares 'audio', but no audio tool exists in TOOLS — "
                    f"skipping round {round_number} without running it.\n"
                )
                round_number += 1
                task_index += 1
                continue

            # Round-scoped fields (Per Round, the default when change_freq is
            # absent) resolve once here and stay fixed for every turn; only
            # genuinely per-turn fields re-resolve each turn inside the story.
            round_fields = resolve_details_fields(
                details_spec, task, MASTER_DETAILS, freq_filter="Per Round"
            )
            details = resolve_details(
                details_spec,
                task,
                MASTER_DETAILS,
                freq_filter="Per Round",
                preferred=round_fields,
            )
            per_turn_task = _has_per_turn_details(details_spec)

            # Deterministic variety: for round-scoped tasks, resolve the
            # combination (round detail fields + mood + genre + role + persona)
            # and re-roll the persona until it has not already been produced in
            # this self-chat window. Tasks with genuine per-turn details skip
            # round-level reservation — variety is enforced turn-by-turn inside
            # run_single_conversation, which also pins the identity fields.
            combo = {}
            for attempt in range(4):
                relationship, mood, persona_details = pick_persona_round_robin(
                    PERSONA_POOL, genre, GENRE_PERSONA_MAP, task_roles=spec.get("roles")
                )
                if per_turn_task:
                    break
                combo = build_combo_dict(genre, mood, persona_details, round_fields)
                if not check_combo_used(token_a, combo):
                    break
                print(
                    f"[theme] Combination already used (attempt {attempt + 1}); "
                    f"re-rolling persona for variety"
                )
            else:
                print(
                    "[theme] Exhausted re-roll attempts; proceeding with the last combination"
                )
            persona = (relationship, mood, persona_details)

            # Share what has already been worked on with the agents BEFORE the
            # task starts, so they coordinate through the tracker.
            themes_block = format_theme_block(
                fetch_used_themes(token_a, scope=SELF_CHAT_THEME_SCOPE)
            )

            # Reserve this combination in the tracker before the round runs, so
            # no later round ever repeats it (even if this one fails). The
            # theme slug is built deterministically from the already-resolved
            # detail fields + mood — no LLM call needed, and combo_hash (the
            # actual dedup key) never reads this field anyway.
            theme_id = None
            if not per_turn_task:
                theme_slug = build_theme_slug(task, mood, combo.get("details") or {})
                logged = theme_api(
                    "log",
                    token_a,
                    operation="log",
                    scope=SELF_CHAT_THEME_SCOPE,
                    theme=theme_slug,
                    **combo,
                )
                theme_id = (
                    (logged.get("theme") or {}).get("id") if logged.get("ok") else None
                )
                if theme_id:
                    print(
                        f"[theme] Reserved combination {theme_id} for round {round_number}"
                    )

            print(
                f"=== Starting round {round_number}: {task} (genre: {genre}, roles: {', '.join(roles)}) ===\n"
            )
            start_time = time.time()
            try:
                transcript, session_a, session_b, fname = run_single_conversation(
                    token_a,
                    token_b,
                    round_number,
                    task,
                    mediums,
                    languages,
                    roles,
                    genre,
                    details,
                    details_spec,
                    checklist,
                    path,
                    context,
                    persona=persona,
                    themes_context=themes_block,
                    turns=spec.get("turns"),
                    round_fields=round_fields,
                    per_turn_task=per_turn_task,
                    research=spec.get("research"),
                    research_turns=spec.get("research_turns") or 1,
                )
            except Exception as e:
                traceback.print_exc()
                print(
                    f"[error] Round {round_number} failed for task '{task}': {e}\n"
                    f"        Skipping to the next task so the flow keeps running."
                )
            else:
                if theme_id:
                    done = theme_api(
                        "complete",
                        token_a,
                        operation="complete",
                        theme_id=theme_id,
                    )
                    if done.get("ok"):
                        print(f"[theme] Marked {theme_id} completed")
                    else:
                        print(
                            f"[theme] Could not mark {theme_id} completed: {done.get('error')}"
                        )
                # save_transcript(transcript, round_number)
                if not keep_sessions:
                    delete_session(token_a, session_a)
                    delete_session(token_b, session_b)
            round_number += 1
            task_index += 1
            elapsed = time.time() - start_time
            print(
                f"Total time elapsed in round {round_number} - {elapsed:.2f} seconds\n"
            )
            print(
                f"Autonomous organization is in vacation for {SLEEP_BETWEEN_ROUNDS} seconds"
            )
            time.sleep(SLEEP_BETWEEN_ROUNDS)
            print("Vacation over")
    except KeyboardInterrupt:
        print("\nManual Interruption")


TASKS, TASKS_SOURCE, TASK_CHECKLISTS, GENRE_PERSONA_MAP, PERSONA_POOL = load_tasks()
if not TASKS:
    print("No tasks to run. Add tasks to a config file and restart.")
    raise SystemExit(1)

GENRE_CHECKLISTS = load_genre_checklists(TASK_CHECKLISTS)
print(f"Loaded {len(TASKS)} task(s) from {TASKS_SOURCE}")

if args.dry_run:
    run_dry_run()
    raise SystemExit(0)

user_input = input("Keep sessions {y/n} [default: n] ? ")
keep_sessions = user_input.strip().lower() == "y"


if __name__ == "__main__":
    run_forever()
</file>

</files>