incognitolm commited on
Commit
0f84d64
·
1 Parent(s): ccd5191
README.md CHANGED
@@ -1,141 +1,10 @@
1
  ---
2
- title: Chat
3
- emoji: 🏆
4
- colorFrom: pink
5
- colorTo: purple
6
  sdk: docker
7
  pinned: false
8
- short_description: Inference Port Web Chat
9
  ---
10
 
11
- HuggingFace Docker Space Node.js WebSocket chat interface for InferencePort AI.
12
-
13
- ## Environment Variables
14
-
15
- Set these as **Secrets** in your HuggingFace Space settings:
16
-
17
- | Variable | Required | Description |
18
- |---|---|---|
19
- | `SUPABASE_ANON_KEY` | Optional | Supabase anon key (falls back to hardcoded value from app) |
20
- | `PUBLIC_URL` | Recommended | Full public URL of your Space, e.g. `https://your-user-your-space.hf.space` |
21
-
22
- No `SUPABASE_SERVICE_ROLE_KEY` is needed — all DB operations use the user's own access token via RLS.
23
-
24
- ## HuggingFace Space Setup
25
-
26
- 1. Create a new **Docker** Space on HuggingFace
27
- 2. Push this repository to the Space
28
- 3. Set `PUBLIC_URL` secret to your space URL (needed for share links)
29
- 4. The app listens on port `7860` which HuggingFace maps automatically
30
-
31
- ## Supabase Tables Required
32
-
33
- Run this SQL in your Supabase project's **SQL Editor**:
34
-
35
- ```sql
36
- -- Web sessions (separate from Electron app's chat_sessions)
37
- CREATE TABLE IF NOT EXISTS public.web_sessions (
38
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
39
- user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
40
- name text NOT NULL DEFAULT 'New Chat',
41
- history jsonb DEFAULT '[]',
42
- model text,
43
- created_at timestamptz DEFAULT now(),
44
- updated_at timestamptz DEFAULT now()
45
- );
46
-
47
- ALTER TABLE public.web_sessions ENABLE ROW LEVEL SECURITY;
48
-
49
- CREATE POLICY "Users can manage their own web sessions"
50
- ON public.web_sessions FOR ALL
51
- USING (auth.uid() = user_id)
52
- WITH CHECK (auth.uid() = user_id);
53
-
54
- -- Shared sessions (readable by anyone, writable by owner)
55
- CREATE TABLE IF NOT EXISTS public.shared_sessions (
56
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
57
- token text UNIQUE NOT NULL,
58
- owner_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
59
- session_snapshot jsonb NOT NULL,
60
- created_at timestamptz DEFAULT now()
61
- );
62
-
63
- ALTER TABLE public.shared_sessions ENABLE ROW LEVEL SECURITY;
64
-
65
- CREATE POLICY "Anyone can read shared sessions"
66
- ON public.shared_sessions FOR SELECT USING (true);
67
-
68
- CREATE POLICY "Owners can insert shared sessions"
69
- ON public.shared_sessions FOR INSERT
70
- WITH CHECK (auth.uid() = owner_id);
71
-
72
- -- User settings (theme, tool toggles)
73
- CREATE TABLE IF NOT EXISTS public.user_settings (
74
- user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
75
- settings jsonb DEFAULT '{}',
76
- updated_at timestamptz DEFAULT now()
77
- );
78
-
79
- ALTER TABLE public.user_settings ENABLE ROW LEVEL SECURITY;
80
-
81
- CREATE POLICY "Users manage own settings"
82
- ON public.user_settings FOR ALL
83
- USING (auth.uid() = user_id)
84
- WITH CHECK (auth.uid() = user_id);
85
- ```
86
-
87
- The `profiles` table already exists from the Electron app and is reused as-is.
88
-
89
- ## File Structure
90
-
91
- ```
92
- inferenceport-web/
93
- ├── Dockerfile
94
- ├── package.json
95
- ├── README.md
96
- ├── server/
97
- │ ├── index.js — Express + WebSocket server entry point
98
- │ ├── wsHandler.js — All WebSocket message routing
99
- │ ├── sessionStore.js — In-memory + Supabase session storage
100
- │ ├── chatStream.js — Lightning API streaming + tool execution
101
- │ ├── auth.js — Supabase auth verification, profile, subscription
102
- │ └── rateLimiter.js — Simple sliding-window rate limiter
103
- └── public/
104
- ├── index.html — Single page app shell
105
- ├── oauth-callback.html — OAuth redirect handler
106
- ├── css/
107
- │ ├── tokens.css — CSS variables (colors, spacing, fonts)
108
- │ ├── base.css — Reset, global styles, animations
109
- │ ├── sidebar.css — Collapsible sidebar, session list
110
- │ ├── chat.css — Chat messages, code blocks, media
111
- │ ├── input.css — Center and bottom input bars
112
- │ └── modals.css — Modal overlays, settings, auth forms
113
- └── js/
114
- ├── ws.js — WebSocket client (auto-reconnect, request/response)
115
- ├── auth.js — Auth state, Supabase sign-in/out, OAuth
116
- ├── sessions.js — Session list management and rendering
117
- ├── chat.js — Chat rendering, streaming, versioning, editing
118
- ├── ui.js — Notifications, context menus, markdown renderer
119
- ├── modals.js — All modal dialogs (auth, share, settings, tool detail)
120
- ├── settings.js — Settings modal, theme application
121
- └── app.js — Bootstrap, input handling, sidebar, paste/attach
122
- ```
123
-
124
- ## Architecture Notes
125
-
126
- - **WebSocket** is the primary transport for all chat, auth, and session operations
127
- - **Server memory** stores active sessions; Supabase is the persistence layer for logged-in users
128
- - **Guest users** get a `tempId` stored in `localStorage`; sessions live in server memory for 12h inactivity / 24h max, with a 15-message daily limit
129
- - **On login**, temp sessions are transferred to the user's Supabase account automatically
130
- - **Chat versioning**: each message stores `versions[]`, where each version contains the message content plus the `tail` (all following messages at time of edit). Navigating versions via the `‹ 1/N ›` arrows replays the stored tail
131
- - **Device sessions** are tracked in server memory only (lost on restart) — no service role key needed
132
- - **Lightning** at `https://sharktide-lightning.hf.space/gen` is used for all chat streaming and media generation
133
-
134
- ## Local Development
135
-
136
- ```bash
137
- npm install
138
- PUBLIC_URL=http://localhost:7860 npm run dev
139
- ```
140
-
141
- Then open `http://localhost:7860`.
 
1
  ---
2
+ title: Chat Dev
3
+ emoji: 🏃
4
+ colorFrom: purple
5
+ colorTo: gray
6
  sdk: docker
7
  pinned: false
 
8
  ---
9
 
10
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app-docs.md ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # InferencePort App Documentation
2
+
3
+ This document gives the assistant a structured reference for the public web interface, the main settings surfaces, account and media flows, and the current privacy and terms policies. Use the section list tool first, then read only the section that answers the user’s question.
4
+
5
+ ## Pages
6
+
7
+ - `index.html` is the main app experience. It includes the chat workspace, bottom composer, welcome screen, sidebar, media panel, settings access, authentication entry points, and share-import banner.
8
+ - `admin.html` is a separate administrator page and is not part of the normal end-user workflow.
9
+ - `privacy.html` explains what data the app collects, how it is used, and what the user can expect from storage and third-party services.
10
+ - `terms.html` explains acceptable use, prohibited use, account expectations, and service disclaimers.
11
+
12
+ ## Chat Workspace
13
+
14
+ - The welcome view is the landing state before a session has visible history. It exposes the centered input for starting a chat.
15
+ - The chat view shows the active conversation, message variants, tool calls, generated media, and message actions like copy, edit, regenerate, and continue.
16
+ - The main composer supports plain text, markdown-style input, attachments, tool toggles, and prompt editing.
17
+ - User message editing creates a new version of the prompt. Follow-up turns continue from the active branch instead of the original branch.
18
+ - Assistant responses can have multiple versions. Users can switch between versions, regenerate a reply, or continue generating from the selected assistant message when appropriate.
19
+
20
+ ## Sidebar And Media Library
21
+
22
+ - The sidebar contains the chat session list, deleted chat access, media library access, settings access, and account shortcuts.
23
+ - The media library shows folders and saved files, including text documents, rich text documents, images, video, audio, and assistant-generated assets.
24
+ - Signed-in users can upload files, create folders, create notes, rename items, move items, edit text documents, restore trashed items, and permanently delete files.
25
+ - Signed-out or guest users are restricted to download and delete behavior in the media area. Upload and creation actions require sign-in.
26
+ - The three-dot item menu exposes contextual actions like download, rename, move, trash, restore, and delete forever depending on item state and the user’s authentication status.
27
+
28
+ ## Settings And Account
29
+
30
+ - The Chat settings tab controls theme and tool toggles for web search, image generation, video generation, and audio generation.
31
+ - The Chat settings tab also shows tool usage progress bars, including the free-plan daily web search limit and other tool quotas returned by the usage service.
32
+ - The Personalization tab lets signed-in users edit a private system prompt that is stored for their account and sent before chats.
33
+ - The Memories tab lets users save, edit, and delete persistent short memories that can influence future chats.
34
+ - The Account tab lets signed-in users edit usernames, review the active subscription plan, inspect current device sessions, revoke other sessions, open billing, and delete the account.
35
+
36
+ ## Modals
37
+
38
+ - Auth modal: sign in, sign up, or continue with supported OAuth providers.
39
+ - Confirm modal: generic confirmation for destructive or sensitive actions.
40
+ - Text prompt modal: used for naming files or folders, entering URLs, and other short-form prompts.
41
+ - Image modal: full-size image preview.
42
+ - Device session modal: details for a logged-in device session with revoke controls.
43
+ - File viewer modal: view or edit attached text files from messages.
44
+ - Tool call modal: shows details of tool arguments and results from assistant tool usage.
45
+ - Media picker modal: choose saved media items to attach into a draft.
46
+ - Folder picker modal: choose a destination folder when moving library items.
47
+
48
+ ## Tool Limits And Credits
49
+
50
+ - Usage data is fetched from `https://sharktide-lightning.hf.space/usage`.
51
+ - The app shows quota progress when that endpoint returns usage metrics such as daily chat credits, image generations, video generations, audio generations, and free-plan web searches.
52
+ - Free accounts have a local limit of 15 web searches per day inside the app. When the limit is reached, the web search tool should explain that the user has hit the daily free-plan search cap and suggest waiting for reset or upgrading.
53
+
54
+ ## Data Collection
55
+
56
+ When a user visits the site, the app may collect or process:
57
+
58
+ - Basic connection metadata such as IP address, browser user agent, and timestamps for security, session tracking, rate limiting, and device session management.
59
+ - Turnstile verification state to confirm the visitor is human before using protected routes or WebSocket actions.
60
+ - Authentication tokens and account identifiers when a user signs in.
61
+ - Chat content, uploaded media, saved memories, settings, personalization prompts, and device session records when those features are used.
62
+ - Usage and subscription information returned by the billing and quota services.
63
+ - Temporary guest identifiers and client identifiers stored in local storage so guest sessions and per-device usage can work.
64
+
65
+ The app should not claim zero collection. It stores and processes the data needed to provide chats, uploads, settings synchronization, abuse prevention, and billing-aware limits.
66
+
67
+ ## Privacy Policy
68
+
69
+ InferencePort uses data to operate the service, store chats and media, apply user settings, enforce limits, detect abuse, and support account management. If a user signs in, account-linked data can be synchronized across devices. If a user stays signed out, temporary identifiers may still be stored locally so guest sessions and limits continue to work.
70
+
71
+ Uploaded files, chat messages, saved memories, and personalization prompts may be stored on the service so the product can retrieve them later. Generated assets may also be stored so they can be re-opened or downloaded. Third-party services may process requests for authentication, verification, billing, quota tracking, web search, and model execution.
72
+
73
+ The privacy policy should explain that:
74
+
75
+ - Users should avoid submitting highly sensitive personal data unless they understand the risks.
76
+ - The service may log operational metadata for debugging, billing, moderation, and abuse prevention.
77
+ - Third-party providers may receive request data needed to fulfill product features.
78
+ - Users can delete chats, media, memories, or accounts through the interface, but operational backups or logs may persist briefly where required for reliability or security.
79
+ - No guarantee of absolute security should be stated.
80
+
81
+ ## Terms Of Service
82
+
83
+ Ethical, legal use is permitted. Users may use the service for ordinary personal, educational, creative, and business tasks so long as their use is lawful and does not harm others or the service.
84
+
85
+ The terms should make clear that users must not:
86
+
87
+ - Use the service to break the law, violate contracts, infringe intellectual property, or bypass security controls.
88
+ - Upload or distribute malware, credential theft content, destructive payloads, or instructions intended for abuse.
89
+ - Harass, exploit, impersonate, defraud, stalk, or intentionally harm other people.
90
+ - Attempt to overload, disrupt, scrape, reverse engineer, or misuse the service infrastructure beyond normal permitted use.
91
+ - Use the service to generate or automate clearly illegal activity.
92
+
93
+ The terms should also explain that:
94
+
95
+ - Users are responsible for the content they submit and the actions they take using the service.
96
+ - Service availability, quotas, model behavior, and features can change over time.
97
+ - The service may suspend access for abusive, unlawful, or dangerous use.
98
+ - Outputs can be wrong, incomplete, or unsafe and should be reviewed before relying on them.
99
+ - InferencePort AI disclaims responsibility for losses or damages resulting from misuse of the service or from users choosing to run risky content.
100
+
101
+ ## Troubleshooting
102
+
103
+ - If uploads are disabled, check whether the user is signed in.
104
+ - If web search stops working, check the daily free-plan search limit.
105
+ - If a message edit seems to branch incorrectly, make sure the current selected message version is the one the user expects before sending the next follow-up.
106
+ - If a response shows a retry button, the assistant request or tool flow failed before completion and can usually be retried directly from the conversation.
package.json CHANGED
@@ -15,6 +15,7 @@
15
  "ws": "^8.16.0",
16
  "openai": "^6.29.0",
17
  "node-fetch": "^3.3.2",
18
- "express-rate-limit": "8.3.2"
 
19
  }
20
  }
 
15
  "ws": "^8.16.0",
16
  "openai": "^6.29.0",
17
  "node-fetch": "^3.3.2",
18
+ "express-rate-limit": "8.3.2",
19
+ "tiktoken": "^1.0.0"
20
  }
21
  }
server/appDocs.js ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const DOCS_PATH = path.resolve(__dirname, '..', 'app-docs.md');
7
+
8
+ let cachedSections = [];
9
+ let cachedMtimeMs = 0;
10
+
11
+ function slugify(text) {
12
+ return String(text || '')
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9]+/g, '-')
15
+ .replace(/^-+|-+$/g, '');
16
+ }
17
+
18
+ async function loadSections() {
19
+ const stats = await fs.stat(DOCS_PATH);
20
+ if (cachedSections.length && cachedMtimeMs === stats.mtimeMs) return cachedSections;
21
+
22
+ const raw = await fs.readFile(DOCS_PATH, 'utf8');
23
+ const parts = raw.split(/^##\s+/m);
24
+ const first = parts.shift() || '';
25
+ const sections = [];
26
+
27
+ const overview = first.replace(/^#.*$/m, '').trim();
28
+ if (overview) {
29
+ sections.push({
30
+ id: 'overview',
31
+ title: 'Overview',
32
+ content: overview,
33
+ });
34
+ }
35
+
36
+ parts.forEach((part) => {
37
+ const lines = part.split('\n');
38
+ const title = (lines.shift() || '').trim();
39
+ const content = lines.join('\n').trim();
40
+ if (!title || !content) return;
41
+ sections.push({
42
+ id: slugify(title),
43
+ title,
44
+ content,
45
+ });
46
+ });
47
+
48
+ cachedSections = sections;
49
+ cachedMtimeMs = stats.mtimeMs;
50
+ return sections;
51
+ }
52
+
53
+ export async function listAppDocSections() {
54
+ const sections = await loadSections();
55
+ return sections.map(({ id, title }) => ({ id, title }));
56
+ }
57
+
58
+ export async function readAppDocSection(sectionId) {
59
+ const target = String(sectionId || '').trim().toLowerCase();
60
+ if (!target) return null;
61
+ const sections = await loadSections();
62
+ return sections.find((section) =>
63
+ section.id === target || section.title.toLowerCase() === target
64
+ ) || null;
65
+ }
server/auth.js CHANGED
@@ -93,13 +93,25 @@ export async function getTierConfig() {
93
  } catch { return null; }
94
  }
95
 
96
- export async function getUsageInfo(accessToken) {
97
  try {
98
  const h = { Accept: 'application/json' };
99
  if (accessToken) h.Authorization = `Bearer ${accessToken}`;
 
100
  const r = await fetch('https://sharktide-lightning.hf.space/usage', { headers: h });
101
- return r.ok ? r.json() : null;
102
- } catch { return null; }
 
 
 
 
 
 
 
 
 
 
 
103
  }
104
 
105
  function defaultSettings() {
 
93
  } catch { return null; }
94
  }
95
 
96
+ export async function getUsageInfo(accessToken, clientId = '') {
97
  try {
98
  const h = { Accept: 'application/json' };
99
  if (accessToken) h.Authorization = `Bearer ${accessToken}`;
100
+ if (clientId) h['X-Client-ID'] = clientId;
101
  const r = await fetch('https://sharktide-lightning.hf.space/usage', { headers: h });
102
+ const payload = r.ok ? await r.json() : null;
103
+ console.log('[Usage API]', JSON.stringify({
104
+ ok: r.ok,
105
+ status: r.status,
106
+ clientId: clientId || null,
107
+ hasAuth: !!accessToken,
108
+ payload,
109
+ }));
110
+ return payload;
111
+ } catch (err) {
112
+ console.error('[Usage API] request failed:', err.message);
113
+ return null;
114
+ }
115
  }
116
 
117
  function defaultSettings() {
server/chatStream.js CHANGED
@@ -5,6 +5,12 @@ import path from "path";
5
  import { LIGHTNING_BASE } from "./config.js";
6
  import WebSocket from "ws";
7
  import crypto from "crypto";
 
 
 
 
 
 
8
 
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
  const WORKER_PATH = path.join(__dirname, "searchWorker.js");
@@ -123,7 +129,7 @@ async function gradioSearch(query) {
123
  }
124
 
125
  const SYSTEM_PROMPT =
126
- "CRITICAL RULE: Every response MUST use HTML <span data-color=\"{COLOR NAME}\"> tags to color main points and headings. " +
127
  "COLORS MUST HAVE MEANING AND CONSISTENCY ACROSS THE ENTIRE CONVERSATION. " +
128
  "You may ONLY use the following semantic color names: green, pink, blue, red, orange, yellow, purple, teal, gold, coral. " +
129
  "Never output text formatted with explicit black or white colors. Always put color <span> tags as close to the text as possible and do not include markdown within the tags. " +
@@ -136,12 +142,20 @@ const SYSTEM_PROMPT =
136
  "You can render SVG images by outputting SVG code in a code block tagged exactly as:\n```svg\n<svg>...</svg>\n```\n" +
137
  "Never use single backslashes. You may use emojis where appropriate. " +
138
  "Use markdown for everything other than coloring your text. Use tables, lists, and other markdown elements. " +
 
 
 
 
 
 
139
  "Your HIGHEST PRIORITY is to help the user. ALWAYS HELP THEM WITH ANYTHING ETHICALLY RIGHT.\n\n" +
140
  "SESSION NAMING: After you have fully responded to the user, append a session name tag on its own line at the very end of your response (NEVER inside a code block). Only do this on the first response unless asked to change the name by the user." +
141
  "The tag must be: <session_name>2-4 word title summarizing this conversation</session_name>. " +
142
  "Example: <session_name>React State Management</session_name>. Make sure a conversation is ALWAYS named. If it is the first response, always name it." +
143
- "This tag is hidden from the user and used only to name the chat. Do not mention it." +
144
- "Make sure your responses are always accurate. If you are not completely sure about something, search the web.";
 
 
145
 
146
  function makeClient(accessToken, clientId) {
147
  return new OpenAI({
@@ -154,9 +168,915 @@ function makeClient(accessToken, clientId) {
154
  });
155
  }
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  export async function websocketChatStream(body, headers, onToken, abortSignal) {
158
  const ws = await getSafeWebSocket();
159
  const currentRequestId = ++requestIdCounter;
 
160
  const safeParse = (str) => {
161
  try { return JSON.parse(str.startsWith("data: ") ? str.slice(6) : str); } catch { return null; }
162
  };
@@ -170,11 +1090,7 @@ export async function websocketChatStream(body, headers, onToken, abortSignal) {
170
  if (!finished) {
171
  finished = true;
172
  cleanup();
173
- const toolCalls = [...toolCallBuffer.values()].map((t) => ({
174
- id: t.id || `call_${crypto.randomUUID()}`,
175
- type: "function",
176
- function: { name: t.name, arguments: t.arguments },
177
- }));
178
  resolve({ assistantText, toolCalls });
179
  }
180
  }, 120000);
@@ -195,19 +1111,80 @@ export async function websocketChatStream(body, headers, onToken, abortSignal) {
195
  if (!payload) return;
196
 
197
  if (payload.error && !payload.choices) {
198
- if (onToken) onToken(`[ERROR] ${JSON.stringify(payload.error)}`);
199
- if (!finished) { finished = true; cleanup(); resolve({ assistantText, toolCalls: [...toolCallBuffer.values()] }); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  return;
201
  }
202
 
203
  const delta = payload.choices?.[0]?.delta;
204
- if (delta?.content) { assistantText += delta.content; if (onToken) onToken(delta.content); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
 
206
  if (delta?.tool_calls) {
207
  for (const call of delta.tool_calls) {
208
  const entry = toolCallBuffer.get(call.index) ?? { arguments: "" };
209
  if (call.id) entry.id = call.id;
210
- if (call.function?.name) entry.name = call.function.name;
 
 
211
  if (call.function?.arguments) entry.arguments += call.function.arguments;
212
  toolCallBuffer.set(call.index, entry);
213
  }
@@ -216,11 +1193,7 @@ export async function websocketChatStream(body, headers, onToken, abortSignal) {
216
  if (payload.choices?.[0]?.finish_reason && !finished) {
217
  finished = true;
218
  cleanup();
219
- const toolCalls = [...toolCallBuffer.values()].map((t) => ({
220
- id: t.id || `call_${crypto.randomUUID()}`,
221
- type: "function",
222
- function: { name: t.name, arguments: t.arguments },
223
- }));
224
  resolve({ assistantText, toolCalls });
225
  }
226
  };
@@ -259,65 +1232,34 @@ export async function streamChat({
259
  history = [],
260
  userMessage,
261
  tools,
 
 
262
  accessToken,
263
  clientId,
 
264
  onToken = () => {},
265
  onDone = () => {},
266
  onError = () => {},
267
  onToolCall = () => {},
268
  onNewAsset = () => {},
 
269
  abortSignal,
270
  }) {
271
-
272
  const enabledTools = buildToolList(tools);
273
-
274
- let normalizedUserMessage = userMessage;
275
-
276
- if (Array.isArray(userMessage)) {
277
-
278
- const hasImages = userMessage.some(item => item.type === "image_url");
279
-
280
- if (hasImages) {
281
-
282
- const textItems = userMessage.filter(
283
- item => item.type === "text" && item.text?.trim()
284
- );
285
-
286
- if (textItems.length === 0) {
287
- normalizedUserMessage = [
288
- { type: "text", text: "[Image(s) attached]" },
289
- ...userMessage.filter(item => item.type === "image_url"),
290
- ];
291
- }
292
-
293
- } else {
294
-
295
- normalizedUserMessage =
296
- userMessage
297
- .filter(b => b.type === "text")
298
- .map(b => b.text)
299
- .join("\n")
300
- .trim() || "";
301
-
302
- }
303
- }
304
-
305
- const hasUserMessage =
306
- userMessage !== undefined &&
307
- userMessage !== null &&
308
- (typeof userMessage === "string" ? userMessage.trim() !== "" : Array.isArray(userMessage) && userMessage.length > 0);
309
-
310
- const messages = [
311
- { role: "system", content: SYSTEM_PROMPT },
312
- ...history.map(normalizeMessage).filter(Boolean),
313
- ];
314
-
315
- if (hasUserMessage) {
316
- messages.push({
317
- role: "user",
318
- content: normalizedUserMessage,
319
- });
320
- }
321
 
322
  const headers = {
323
  ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
@@ -325,108 +1267,129 @@ export async function streamChat({
325
  };
326
 
327
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
 
329
- const body = {
330
- model: model || "lightning",
331
- messages,
332
- tools: enabledTools.length ? enabledTools : undefined,
333
- stream: true,
334
- };
335
-
336
- let { assistantText, toolCalls } =
337
- await websocketChatStream(body, headers, onToken, abortSignal);
338
-
339
- if (toolCalls.length > 0) {
340
-
341
- const toolResults = await processToolCalls(
342
- null,
343
- toolCalls,
344
- tools,
345
- accessToken,
346
- clientId,
347
- abortSignal,
348
- onToolCall,
349
- onNewAsset
350
  );
351
 
352
- const followUpMessages = [
353
- { role: "system", content: SYSTEM_PROMPT },
354
- ...history.map(normalizeMessage).filter(Boolean),
355
- ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
 
357
- if (hasUserMessage) {
358
- followUpMessages.push({
359
- role: "user",
360
- content: normalizedUserMessage,
 
 
 
 
 
 
 
 
 
 
 
 
361
  });
 
 
 
 
 
 
 
 
 
 
362
  }
 
363
 
364
- followUpMessages.push(
 
 
365
  {
366
- role: "assistant",
367
- content: assistantText || "",
368
- tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
369
  },
370
- ...toolResults
371
- );
372
 
373
- const followUpBody = {
374
- model: model || "lightning",
375
- messages: followUpMessages,
376
- tools: enabledTools,
377
- stream: true,
378
- };
 
 
 
 
379
 
380
- let followUpAssistantText = "";
381
- for (let attempt = 0; attempt < 2; attempt++) {
382
- const followUp = await websocketChatStream(followUpBody, headers, onToken, abortSignal);
383
- followUpAssistantText += followUp.assistantText;
384
-
385
- // If the model returned new tool calls, process them and update the body
386
- if (followUp.toolCalls.length > 0) {
387
- const toolResults = await processToolCalls(
388
- null,
389
- followUp.toolCalls,
390
- tools,
391
- accessToken,
392
- clientId,
393
- abortSignal,
394
- onToolCall,
395
- onNewAsset
396
- );
397
- followUpMessages.push(...toolResults);
398
-
399
- // Update body for another pass
400
- followUpBody.messages = followUpMessages;
401
- } else {
402
- break; // no new tool calls, we’re done
403
- }
404
  }
405
- assistantText += followUpAssistantText;
 
 
 
 
406
  }
407
 
408
  const sessionName = extractSessionName(assistantText);
409
 
410
  if (typeof onDone === "function") {
411
- onDone(assistantText, toolCalls, false, sessionName);
412
  }
413
 
414
- } catch (err) {
415
 
416
- console.error("streamChat error:", err);
417
-
418
- if (
419
- err.name === "AbortError" ||
420
- err.message === "AbortError"
421
- ) {
422
- if (typeof onDone === "function") {
423
- onDone(null, null, true, null);
424
- }
425
  } else {
426
- console.error("streamChat error:", err);
427
- if (typeof onError === "function") {
428
- onError(String(err));
429
- }
430
  }
431
  }
432
  }
@@ -436,8 +1399,22 @@ const VALID_ROLES = new Set(["system", "user", "assistant", "tool"]);
436
  function normalizeMessage(msg) {
437
  if (!VALID_ROLES.has(msg.role)) return null;
438
 
439
- if (msg.role === "assistant" && msg.tool_calls) {
440
- return { role: "assistant", content: "", tool_calls: msg.tool_calls };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
441
  }
442
  if (Array.isArray(msg.content)) {
443
  // If the array contains images, preserve the full array format
@@ -456,9 +1433,149 @@ function normalizeMessage(msg) {
456
  }
457
 
458
  function buildToolList(tools) {
459
- if (!tools) return [];
460
  const list = [];
461
- if (tools.webSearch) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
  list.push({
463
  type: "function",
464
  function: {
@@ -484,7 +1601,7 @@ function buildToolList(tools) {
484
  },
485
  });
486
  }
487
- if (tools.imageGen) {
488
  list.push({
489
  type: "function",
490
  function: {
@@ -502,7 +1619,7 @@ function buildToolList(tools) {
502
  },
503
  });
504
  }
505
- if (tools.videoGen) {
506
  list.push({
507
  type: "function",
508
  function: {
@@ -522,7 +1639,7 @@ function buildToolList(tools) {
522
  },
523
  });
524
  }
525
- if (tools.audioGen) {
526
  list.push({
527
  type: "function",
528
  function: {
@@ -539,9 +1656,84 @@ function buildToolList(tools) {
539
  return list;
540
  }
541
 
542
- async function processToolCalls(ws, toolCalls, tools, accessToken, clientId, abortSignal, onToolCall, onNewAsset) {
543
- const toolResults = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
544
  const authHeaders = {};
 
545
  if (accessToken) {
546
  authHeaders["Authorization"] = `Bearer ${accessToken}`;
547
  }
@@ -552,17 +1744,168 @@ async function processToolCalls(ws, toolCalls, tools, accessToken, clientId, abo
552
  for (const call of toolCalls) {
553
  let args;
554
  try { args = JSON.parse(call.function.arguments || "{}"); } catch { args = {}; }
 
 
 
 
555
 
556
- onToolCall({ id: call.id, name: call.function.name, state: "pending", args });
557
 
558
  let result = "Tool completed.";
559
 
560
  try {
561
- if (call.function.name === "ollama_search") {
562
- result = await gradioSearch(args.query);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
  }
564
 
565
- else if (call.function.name === "read_web_page") {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
  const { convert } = await import("html-to-text");
567
  const res = await fetch(args.url, { signal: abortSignal });
568
  if (!res.ok) {
@@ -577,7 +1920,7 @@ async function processToolCalls(ws, toolCalls, tools, accessToken, clientId, abo
577
  }
578
  }
579
 
580
- else if (call.function.name === "generate_image") {
581
  const body = { prompt: args.prompt };
582
  if (args.mode) body.mode = args.mode;
583
  if (args.image_urls?.length) body.image_urls = args.image_urls;
@@ -591,9 +1934,15 @@ async function processToolCalls(ws, toolCalls, tools, accessToken, clientId, abo
591
  if (res.ok) {
592
  const buf = await res.arrayBuffer();
593
  const ct = res.headers.get("content-type") || "image/png";
594
- const b64 = Buffer.from(buf).toString("base64");
595
- const dataUrl = `data:${ct};base64,${b64}`;
596
- onNewAsset({ role: "image", content: dataUrl });
 
 
 
 
 
 
597
  result = "Image generated successfully and shown to the user.";
598
  } else if (res.status == 402) {
599
  result = "An upgraded plan is required for higher limits.";
@@ -604,7 +1953,7 @@ async function processToolCalls(ws, toolCalls, tools, accessToken, clientId, abo
604
  }
605
  }
606
 
607
- else if (call.function.name === "generate_video") {
608
  const body = { prompt: args.prompt };
609
  if (args.ratio) body.ratio = args.ratio;
610
  if (args.mode) body.mode = args.mode;
@@ -619,9 +1968,16 @@ async function processToolCalls(ws, toolCalls, tools, accessToken, clientId, abo
619
  });
620
  if (res.ok) {
621
  const buf = await res.arrayBuffer();
622
- const b64 = Buffer.from(buf).toString("base64");
623
- const dataUrl = `data:video/mp4;base64,${b64}`;
624
- onNewAsset({ role: "video", content: dataUrl });
 
 
 
 
 
 
 
625
  result = "Video generated successfully and shown to the user.";
626
  } else if (res.status == 402) {
627
  result = "An upgraded plan is required for higher limits.";
@@ -632,7 +1988,7 @@ async function processToolCalls(ws, toolCalls, tools, accessToken, clientId, abo
632
  }
633
  }
634
 
635
- else if (call.function.name === "generate_audio") {
636
  const res = await fetch(`${LIGHTNING_BASE}/gen/sfx`, {
637
  method: "POST",
638
  headers: { "Content-Type": "application/json", ...authHeaders },
@@ -641,9 +1997,16 @@ async function processToolCalls(ws, toolCalls, tools, accessToken, clientId, abo
641
  });
642
  if (res.ok) {
643
  const buf = await res.arrayBuffer();
644
- const b64 = Buffer.from(buf).toString("base64");
645
- const dataUrl = `data:audio/mpeg;base64,${b64}`;
646
- onNewAsset({ role: "audio", content: dataUrl });
 
 
 
 
 
 
 
647
  result = "Audio generated successfully and shown to the user.";
648
  } else if (res.status == 429) {
649
  result = "Too many requests. Try again later.";
@@ -655,14 +2018,14 @@ async function processToolCalls(ws, toolCalls, tools, accessToken, clientId, abo
655
  result = `Tool error: ${String(err)}`;
656
  }
657
 
658
- onToolCall({ id: call.id, name: call.function.name, state: "resolved", result });
659
 
660
- toolResults.push({
661
  role: "tool",
662
  tool_call_id: call.id,
663
  content: typeof result === "string" ? result : JSON.stringify(result),
664
  });
665
  }
666
 
667
- return toolResults;
668
- }
 
5
  import { LIGHTNING_BASE } from "./config.js";
6
  import WebSocket from "ws";
7
  import crypto from "crypto";
8
+ import { encoding_for_model } from "tiktoken";
9
+ import { mediaStore } from "./mediaStore.js";
10
+ import { memoryStore } from "./memoryStore.js";
11
+ import { systemPromptStore } from "./systemPromptStore.js";
12
+ import { consumeWebSearchUsage } from "./webSearchUsageStore.js";
13
+ import { listAppDocSections, readAppDocSection } from "./appDocs.js";
14
 
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
  const WORKER_PATH = path.join(__dirname, "searchWorker.js");
 
129
  }
130
 
131
  const SYSTEM_PROMPT =
132
+ "CRITICAL RULE: Every response MUST use HTML <span data-color=\"{COLOR NAME}\"> tags to color main points and headings unless you are told otherwise. " +
133
  "COLORS MUST HAVE MEANING AND CONSISTENCY ACROSS THE ENTIRE CONVERSATION. " +
134
  "You may ONLY use the following semantic color names: green, pink, blue, red, orange, yellow, purple, teal, gold, coral. " +
135
  "Never output text formatted with explicit black or white colors. Always put color <span> tags as close to the text as possible and do not include markdown within the tags. " +
 
142
  "You can render SVG images by outputting SVG code in a code block tagged exactly as:\n```svg\n<svg>...</svg>\n```\n" +
143
  "Never use single backslashes. You may use emojis where appropriate. " +
144
  "Use markdown for everything other than coloring your text. Use tables, lists, and other markdown elements. " +
145
+ "ATTACHMENT HANDLING RULE: Large user prompts, text attachments, conversation history, and image attachments may be staged into separate resources on purpose. " +
146
+ "If you see notes saying attached text was staged separately, or notes that only the first part of a prompt is inline, do NOT assume the content is missing, corrupted, or truncated. " +
147
+ "Treat that content as available context and use the provided tools to inspect it before concluding something is absent. " +
148
+ "Use `list_prompt_resources` to find staged resources, `read_prompt_chunk` to read staged text exactly, `load_prompt_images` to inspect staged images, and `write_notes` to keep a compact memory after reading several chunks. " +
149
+ "Before claiming an attachment is incomplete, missing, malformed, or unreadable, first check whether it was staged separately and read the relevant resource. " +
150
+ "Persistent memories must stay short, concrete, and durable. Only save memories that will still help in future chats, and keep each one to a brief sentence or phrase. " +
151
  "Your HIGHEST PRIORITY is to help the user. ALWAYS HELP THEM WITH ANYTHING ETHICALLY RIGHT.\n\n" +
152
  "SESSION NAMING: After you have fully responded to the user, append a session name tag on its own line at the very end of your response (NEVER inside a code block). Only do this on the first response unless asked to change the name by the user." +
153
  "The tag must be: <session_name>2-4 word title summarizing this conversation</session_name>. " +
154
  "Example: <session_name>React State Management</session_name>. Make sure a conversation is ALWAYS named. If it is the first response, always name it." +
155
+ "This tag is hidden from the user and used only to name the chat. Do not mention it. Also, you should try to minimize the use of * to emphasize text. Just use it for markdown." +
156
+ "Make sure your responses are always accurate. If you are not completely sure about something, search the web." +
157
+ "At the start of a chat, always check the memories. If the user tells you to remember something or there is something important to note, create a new memory. Memories should be brief. Notes are only for session-long memory, so use memories for anything relevat to future chats." +
158
+ "If you notice any issue or mistake with your response, correct it with the replace tools. Make sure to ALWAYS answer as CORRECTLY as possible, and use search when unsure.";
159
 
160
  function makeClient(accessToken, clientId) {
161
  return new OpenAI({
 
168
  });
169
  }
170
 
171
+ // --- TOKEN / CONTEXT MANAGEMENT ---
172
+ const MODEL_TOKEN_LIMIT = 8000;
173
+ const RESPONSE_TOKEN_RESERVE = 600;
174
+ const WORKING_PROMPT_BUDGET = MODEL_TOKEN_LIMIT - RESPONSE_TOKEN_RESERVE;
175
+ const INLINE_USER_PROMPT_TOKENS = 5000;
176
+ const MAX_AGENT_STEPS = 12;
177
+ const CHUNK_SIZE = 700;
178
+ const MAX_INLINE_IMAGES = 3;
179
+ const RECENT_HISTORY_MAX_MESSAGES = 6;
180
+ const RECENT_HISTORY_TOKEN_BUDGET = 900;
181
+ const HISTORY_SUMMARY_TOKEN_BUDGET = 600;
182
+ const NOTES_TOKEN_BUDGET = 900;
183
+ const MAX_DYNAMIC_MESSAGES = 10;
184
+ const MAX_UPSTREAM_RATE_LIMIT_RETRIES = 4;
185
+ const DEFAULT_UPSTREAM_RETRY_MS = 4000;
186
+ const MAX_UPSTREAM_RETRY_MS = 15000;
187
+ const UPSTREAM_RETRY_BUFFER_MS = 350;
188
+
189
+ // In-memory stores for staged prompt resources and assistant notes
190
+ const promptContextStore = new Map(); // sessionId -> { resources, resourcesById }
191
+ const assistantNotesStore = new Map(); // sessionId -> [{ step, note }]
192
+
193
+ // Initialize tiktoken encoder
194
+ const enc = encoding_for_model("gpt-4"); // maps well to gpt-oss:120b
195
+ const tokenDecoder = new TextDecoder("utf-8");
196
+
197
+ function countTokens(text) {
198
+ return enc.encode(String(text || "")).length;
199
+ }
200
+
201
+ function decodeTokenSlice(tokens) {
202
+ const decoded = enc.decode(tokens);
203
+ return typeof decoded === "string" ? decoded : tokenDecoder.decode(decoded);
204
+ }
205
+
206
+ function countMessageTokens(messages) {
207
+ let total = 0;
208
+ for (const msg of messages) {
209
+ if (typeof msg.content === "string") {
210
+ total += countTokens(msg.content);
211
+ continue;
212
+ }
213
+ if (Array.isArray(msg.content)) {
214
+ for (const block of msg.content) {
215
+ if (block.type === "text") total += countTokens(block.text);
216
+ else if (block.type === "image_url") total += 85;
217
+ }
218
+ }
219
+ }
220
+ return total;
221
+ }
222
+
223
+ function compactWhitespace(text) {
224
+ return String(text || "").replace(/\s+/g, " ").trim();
225
+ }
226
+
227
+ function makePreview(text, maxChars = 160) {
228
+ const compact = compactWhitespace(text);
229
+ if (!compact) return "Empty.";
230
+ return compact.length <= maxChars ? compact : `${compact.slice(0, maxChars - 3)}...`;
231
+ }
232
+
233
+ function stripHtml(text) {
234
+ return String(text || "").replace(/<[^>]+>/g, " ");
235
+ }
236
+
237
+ function splitTextByTokenLimit(text, tokenLimit) {
238
+ const source = String(text || "");
239
+ const tokens = enc.encode(source);
240
+ if (tokens.length <= tokenLimit) {
241
+ return { head: source, tail: "" };
242
+ }
243
+ return {
244
+ head: decodeTokenSlice(tokens.slice(0, tokenLimit)),
245
+ tail: decodeTokenSlice(tokens.slice(tokenLimit)),
246
+ };
247
+ }
248
+
249
+ function chunkTextWithMetadata(text, chunkSize = CHUNK_SIZE) {
250
+ const source = String(text || "");
251
+ const tokens = enc.encode(source);
252
+ const chunks = [];
253
+
254
+ for (let i = 0; i < tokens.length; i += chunkSize) {
255
+ const chunkText = decodeTokenSlice(tokens.slice(i, i + chunkSize));
256
+ chunks.push({
257
+ index: chunks.length,
258
+ text: chunkText,
259
+ tokenCount: countTokens(chunkText),
260
+ preview: makePreview(chunkText, 120),
261
+ });
262
+ }
263
+
264
+ return chunks;
265
+ }
266
+
267
+ function makeResourceSlug(value) {
268
+ return compactWhitespace(value)
269
+ .toLowerCase()
270
+ .replace(/[^a-z0-9]+/g, "_")
271
+ .replace(/^_+|_+$/g, "")
272
+ .slice(0, 40) || "resource";
273
+ }
274
+
275
+ function createPromptState(sessionId) {
276
+ const state = {
277
+ sessionId,
278
+ createdAt: Date.now(),
279
+ resources: [],
280
+ resourcesById: new Map(),
281
+ };
282
+ promptContextStore.set(sessionId, state);
283
+ assistantNotesStore.set(sessionId, []);
284
+ return state;
285
+ }
286
+
287
+ function getPromptState(sessionId) {
288
+ return promptContextStore.get(sessionId);
289
+ }
290
+
291
+ function clearPromptState(sessionId) {
292
+ promptContextStore.delete(sessionId);
293
+ assistantNotesStore.delete(sessionId);
294
+ }
295
+
296
+ function createResourceId(state, kind, name) {
297
+ const base = `${kind}_${makeResourceSlug(name)}`;
298
+ let id = base;
299
+ let suffix = 2;
300
+
301
+ while (state.resourcesById.has(id)) {
302
+ id = `${base}_${suffix++}`;
303
+ }
304
+
305
+ return id;
306
+ }
307
+
308
+ function registerTextResource(state, { kind, name, text }) {
309
+ const normalizedText = String(text ?? "").trim();
310
+ if (!normalizedText) return null;
311
+
312
+ const resource = {
313
+ id: createResourceId(state, kind, name),
314
+ kind,
315
+ name,
316
+ totalTokens: countTokens(normalizedText),
317
+ chunkCount: 0,
318
+ preview: makePreview(normalizedText),
319
+ chunks: chunkTextWithMetadata(normalizedText),
320
+ };
321
+ resource.chunkCount = resource.chunks.length;
322
+
323
+ state.resources.push(resource);
324
+ state.resourcesById.set(resource.id, resource);
325
+ return resource;
326
+ }
327
+
328
+ function registerImageResource(state, { name, images, inlineImageCount = 0 }) {
329
+ const normalizedImages = (images || [])
330
+ .filter(Boolean)
331
+ .map((block, index) => ({
332
+ index,
333
+ block: { ...block },
334
+ }));
335
+
336
+ if (!normalizedImages.length) return null;
337
+
338
+ const resource = {
339
+ id: createResourceId(state, "images", name),
340
+ kind: "images",
341
+ name,
342
+ imageCount: normalizedImages.length,
343
+ inlineImageCount,
344
+ preview: `${normalizedImages.length} image(s)`,
345
+ images: normalizedImages,
346
+ };
347
+
348
+ state.resources.push(resource);
349
+ state.resourcesById.set(resource.id, resource);
350
+ return resource;
351
+ }
352
+
353
+ function parseTextAttachmentsFromDetails(text) {
354
+ const source = String(text || "");
355
+ if (!source.trim()) return { text: "", attachments: [] };
356
+
357
+ const detailsRegex = /<details>\s*<summary>(.*?)<\/summary>\s*([\s\S]*?)<\/details>/gi;
358
+ const codeBlockRegex = /```(?:[\w+-]+)?\n?([\s\S]*?)```/g;
359
+ const attachments = [];
360
+ const parts = [];
361
+ let lastIndex = 0;
362
+ let attachmentCounter = 1;
363
+ let match;
364
+
365
+ while ((match = detailsRegex.exec(source))) {
366
+ parts.push(source.slice(lastIndex, match.index));
367
+
368
+ const summary = compactWhitespace(match[1]) || `attachment_${attachmentCounter}`;
369
+ const innerContent = match[2] || "";
370
+ const codeBlocks = [...innerContent.matchAll(codeBlockRegex)];
371
+
372
+ if (codeBlocks.length) {
373
+ codeBlocks.forEach((codeMatch, index) => {
374
+ attachments.push({
375
+ name: codeBlocks.length > 1 ? `${summary} part ${index + 1}` : summary,
376
+ content: codeMatch[1],
377
+ });
378
+ });
379
+ } else {
380
+ const plainText = stripHtml(innerContent).trim();
381
+ if (plainText) {
382
+ attachments.push({ name: summary, content: plainText });
383
+ }
384
+ }
385
+
386
+ parts.push(`\n[Attached text "${summary}" was staged separately from the inline prompt.]\n`);
387
+ lastIndex = match.index + match[0].length;
388
+ attachmentCounter++;
389
+ }
390
+
391
+ parts.push(source.slice(lastIndex));
392
+ return {
393
+ text: parts.join("").trim(),
394
+ attachments,
395
+ };
396
+ }
397
+
398
+ function contentToText(content, { preview = false } = {}) {
399
+ if (typeof content === "string") {
400
+ return preview ? makePreview(content, 220) : String(content || "");
401
+ }
402
+
403
+ if (!Array.isArray(content)) return "";
404
+
405
+ const textParts = [];
406
+ let imageCount = 0;
407
+
408
+ for (const block of content) {
409
+ if (block.type === "text" && block.text) textParts.push(block.text);
410
+ else if (block.type === "image_url") imageCount++;
411
+ }
412
+
413
+ const pieces = [];
414
+ const text = textParts.join("\n\n").trim();
415
+ if (text) pieces.push(preview ? makePreview(text, 220) : text);
416
+ if (imageCount) pieces.push(`[${imageCount} image attachment(s)]`);
417
+ return pieces.join(preview ? " " : "\n");
418
+ }
419
+
420
+ function getAllowedToolNames(toolDefs = []) {
421
+ return toolDefs
422
+ .map((tool) => tool?.function?.name)
423
+ .filter(Boolean);
424
+ }
425
+
426
+ function sanitizeToolName(rawName, allowedToolNames = []) {
427
+ if (!rawName) return null;
428
+
429
+ let name = String(rawName).trim();
430
+ if (!name) return null;
431
+
432
+ name = name.replace(/<\|[\s\S]*$/, "").trim();
433
+ name = name.replace(/^["'`]+|["'`]+$/g, "");
434
+
435
+ const identifierMatch = name.match(/^[A-Za-z0-9_-]+/);
436
+ if (identifierMatch) {
437
+ name = identifierMatch[0];
438
+ }
439
+
440
+ if (!allowedToolNames.length) {
441
+ return name || null;
442
+ }
443
+
444
+ if (allowedToolNames.includes(name)) {
445
+ return name;
446
+ }
447
+
448
+ const normalizedRaw = String(rawName).toLowerCase();
449
+ const prefixedMatch = allowedToolNames.find((allowedName) =>
450
+ normalizedRaw.startsWith(allowedName.toLowerCase())
451
+ );
452
+ if (prefixedMatch) {
453
+ return prefixedMatch;
454
+ }
455
+
456
+ const embeddedMatch = allowedToolNames.find((allowedName) =>
457
+ normalizedRaw.includes(allowedName.toLowerCase())
458
+ );
459
+ return embeddedMatch || null;
460
+ }
461
+
462
+ function normalizeStoredToolCalls(toolCalls = []) {
463
+ return toolCalls.map((call) => ({
464
+ id: call.id || `call_${crypto.randomUUID()}`,
465
+ type: "function",
466
+ function: {
467
+ name: sanitizeToolName(call.name || call.function?.name || "unknown_tool"),
468
+ arguments: (() => {
469
+ const rawArgs = call.args ?? call.function?.arguments ?? {};
470
+ return typeof rawArgs === "string" ? rawArgs : JSON.stringify(rawArgs);
471
+ })(),
472
+ },
473
+ }));
474
+ }
475
+
476
+ function getMessageToolNames(msg) {
477
+ const rawCalls = Array.isArray(msg.tool_calls)
478
+ ? msg.tool_calls
479
+ : Array.isArray(msg.toolCalls)
480
+ ? msg.toolCalls
481
+ : [];
482
+
483
+ return rawCalls
484
+ .map((call) => call?.function?.name || call?.name)
485
+ .filter(Boolean);
486
+ }
487
+
488
+ function describeMessageForSummary(msg) {
489
+ const role = (msg.role || "message").toUpperCase();
490
+ const contentPreview = contentToText(msg.content, { preview: true }) || "[no text]";
491
+ const toolNames = getMessageToolNames(msg);
492
+ const suffix = toolNames.length ? ` [tools: ${toolNames.join(", ")}]` : "";
493
+ return `${role}: ${contentPreview}${suffix}`;
494
+ }
495
+
496
+ function messagesToTranscript(messages) {
497
+ return messages
498
+ .map((msg) => {
499
+ const toolNames = getMessageToolNames(msg);
500
+ const lines = [
501
+ `${(msg.role || "message").toUpperCase()}:`,
502
+ contentToText(msg.content) || "[no text]",
503
+ ];
504
+ if (toolNames.length) {
505
+ lines.push(`[tool calls: ${toolNames.join(", ")}]`);
506
+ }
507
+ return lines.join("\n");
508
+ })
509
+ .join("\n\n");
510
+ }
511
+
512
+ function buildHistorySummary(messages, tokenBudget = HISTORY_SUMMARY_TOKEN_BUDGET) {
513
+ const lines = [];
514
+ let usedTokens = 0;
515
+
516
+ for (let i = 0; i < messages.length; i++) {
517
+ const line = `- ${describeMessageForSummary(messages[i])}`;
518
+ const lineTokens = countTokens(line);
519
+ if (usedTokens + lineTokens > tokenBudget) {
520
+ lines.push(`- ${messages.length - i} earlier message(s) were omitted from this summary.`);
521
+ break;
522
+ }
523
+ lines.push(line);
524
+ usedTokens += lineTokens;
525
+ }
526
+
527
+ return lines.join("\n");
528
+ }
529
+
530
+ function buildPromptResourceManifest(state) {
531
+ return {
532
+ resource_count: state?.resources?.length || 0,
533
+ resources: (state?.resources || []).map((resource) => {
534
+ if (resource.kind === "images") {
535
+ return {
536
+ id: resource.id,
537
+ kind: resource.kind,
538
+ name: resource.name,
539
+ image_count: resource.imageCount,
540
+ inline_image_count: resource.inlineImageCount,
541
+ preview: resource.preview,
542
+ };
543
+ }
544
+
545
+ return {
546
+ id: resource.id,
547
+ kind: resource.kind,
548
+ name: resource.name,
549
+ chunk_count: resource.chunkCount,
550
+ total_tokens: resource.totalTokens,
551
+ preview: resource.preview,
552
+ };
553
+ }),
554
+ };
555
+ }
556
+
557
+ function formatResourceManifestLine(resource) {
558
+ if (resource.kind === "images") {
559
+ const inlineNote = resource.inlineImageCount
560
+ ? `, first ${resource.inlineImageCount} already inline`
561
+ : "";
562
+ return `- \`${resource.id}\` (${resource.kind}, ${resource.imageCount} image(s)${inlineNote})`;
563
+ }
564
+
565
+ return `- \`${resource.id}\` (${resource.kind}, ${resource.chunkCount} chunk(s), ${resource.totalTokens} tokens): ${resource.preview}`;
566
+ }
567
+
568
+ function findPromptResource(state, resourceId) {
569
+ if (!state || !resourceId) return null;
570
+ return (
571
+ state.resourcesById.get(resourceId) ||
572
+ state.resources.find((resource) => resource.name === resourceId) ||
573
+ null
574
+ );
575
+ }
576
+
577
+ function appendAssistantNote(sessionId, note) {
578
+ const normalizedNote = compactWhitespace(note);
579
+ if (!normalizedNote) return;
580
+
581
+ const notes = assistantNotesStore.get(sessionId) || [];
582
+ if (notes[notes.length - 1]?.note === normalizedNote) return;
583
+
584
+ notes.push({ step: notes.length + 1, note: normalizedNote });
585
+
586
+ while (
587
+ notes.length > 1 &&
588
+ countTokens(notes.map((entry) => `- ${entry.note}`).join("\n")) > NOTES_TOKEN_BUDGET
589
+ ) {
590
+ notes.shift();
591
+ }
592
+
593
+ assistantNotesStore.set(sessionId, notes);
594
+ }
595
+
596
+ function buildAssistantNotesMessage(sessionId) {
597
+ const notes = assistantNotesStore.get(sessionId) || [];
598
+ if (!notes.length) return null;
599
+
600
+ return {
601
+ role: "system",
602
+ content: [
603
+ "Working notes captured during this response:",
604
+ ...notes.map((entry) => `- ${entry.note}`),
605
+ ].join("\n"),
606
+ };
607
+ }
608
+
609
+ function buildCompactionSummaryMessage(messages) {
610
+ if (!messages.length) return null;
611
+
612
+ const toolNames = new Set();
613
+ let toolResultCount = 0;
614
+ let imageBatchCount = 0;
615
+
616
+ for (const msg of messages) {
617
+ for (const name of getMessageToolNames(msg)) {
618
+ toolNames.add(name);
619
+ }
620
+ if (msg.role === "tool") toolResultCount++;
621
+ if (
622
+ msg.role === "user" &&
623
+ Array.isArray(msg.content) &&
624
+ msg.content.some((block) => block.type === "image_url")
625
+ ) {
626
+ imageBatchCount++;
627
+ }
628
+ }
629
+
630
+ const details = [];
631
+ if (toolNames.size) details.push(`tools: ${[...toolNames].join(", ")}`);
632
+ if (toolResultCount) details.push(`${toolResultCount} prior tool result(s)`);
633
+ if (imageBatchCount) details.push(`${imageBatchCount} prior image batch(es)`);
634
+
635
+ return {
636
+ role: "system",
637
+ content: `Earlier tool interactions in this response were compacted to stay within the context window${details.length ? ` (${details.join("; ")})` : ""}. Reread any chunk or image batch if you need the exact content again.`,
638
+ };
639
+ }
640
+
641
+ function buildModelMessages(baseMessages, workingMessages, sessionId) {
642
+ const notesMessage = buildAssistantNotesMessage(sessionId);
643
+ const messages = [...baseMessages];
644
+ if (notesMessage) messages.push(notesMessage);
645
+
646
+ const budgetLeft = Math.max(0, WORKING_PROMPT_BUDGET - countMessageTokens(messages));
647
+ const keptMessages = [];
648
+ let keptTokens = 0;
649
+
650
+ for (let i = workingMessages.length - 1; i >= 0; i--) {
651
+ const candidate = workingMessages[i];
652
+ const candidateTokens = countMessageTokens([candidate]);
653
+ if (keptMessages.length >= MAX_DYNAMIC_MESSAGES) {
654
+ break;
655
+ }
656
+ if (keptMessages.length > 0 && keptTokens + candidateTokens > budgetLeft) {
657
+ break;
658
+ }
659
+ keptMessages.unshift(candidate);
660
+ keptTokens += candidateTokens;
661
+ if (keptMessages.length === 1 && candidateTokens > budgetLeft) {
662
+ break;
663
+ }
664
+ }
665
+
666
+ const omittedMessages = workingMessages.slice(0, workingMessages.length - keptMessages.length);
667
+ const summaryMessage = buildCompactionSummaryMessage(omittedMessages);
668
+ if (summaryMessage) {
669
+ const withSummary = [...messages, summaryMessage, ...keptMessages];
670
+ if (countMessageTokens(withSummary) <= WORKING_PROMPT_BUDGET) {
671
+ messages.push(summaryMessage);
672
+ }
673
+ }
674
+
675
+ messages.push(...keptMessages);
676
+ return messages;
677
+ }
678
+
679
+ function prepareHistoryContext(history, state) {
680
+ const normalizedHistory = history.filter(Boolean);
681
+ if (!normalizedHistory.length) {
682
+ return { summaryMessages: [], recentMessages: [] };
683
+ }
684
+
685
+ const recentMessages = [];
686
+ let recentTokens = 0;
687
+
688
+ for (let i = normalizedHistory.length - 1; i >= 0; i--) {
689
+ const candidate = normalizedHistory[i];
690
+ const candidateTokens = countMessageTokens([candidate]);
691
+ if (
692
+ recentMessages.length >= RECENT_HISTORY_MAX_MESSAGES ||
693
+ recentTokens + candidateTokens > RECENT_HISTORY_TOKEN_BUDGET
694
+ ) {
695
+ break;
696
+ }
697
+ recentMessages.unshift(candidate);
698
+ recentTokens += candidateTokens;
699
+ }
700
+
701
+ const olderMessages = normalizedHistory.slice(0, normalizedHistory.length - recentMessages.length);
702
+ if (!olderMessages.length) {
703
+ return { summaryMessages: [], recentMessages };
704
+ }
705
+
706
+ const historyResource = registerTextResource(state, {
707
+ kind: "history",
708
+ name: "Earlier conversation",
709
+ text: messagesToTranscript(olderMessages),
710
+ });
711
+
712
+ const summaryMessages = historyResource
713
+ ? [
714
+ {
715
+ role: "system",
716
+ content: [
717
+ "Earlier conversation was condensed to keep the live prompt smaller.",
718
+ `Summary:\n${buildHistorySummary(olderMessages)}`,
719
+ `If exact earlier wording matters, use \`read_prompt_chunk\` with resource_id "${historyResource.id}".`,
720
+ ].join("\n\n"),
721
+ },
722
+ ]
723
+ : [];
724
+
725
+ return { summaryMessages, recentMessages };
726
+ }
727
+
728
+ function prepareCurrentUserContext(userMessage, state) {
729
+ const attachments = [];
730
+ const imageBlocks = [];
731
+ const textParts = [];
732
+
733
+ if (typeof userMessage === "string") {
734
+ const parsed = parseTextAttachmentsFromDetails(userMessage);
735
+ if (parsed.text) textParts.push(parsed.text);
736
+ attachments.push(...parsed.attachments);
737
+ } else if (Array.isArray(userMessage)) {
738
+ for (const item of userMessage) {
739
+ if (item?.type === "text" && item.text?.trim()) {
740
+ const parsed = parseTextAttachmentsFromDetails(item.text);
741
+ if (parsed.text) textParts.push(parsed.text);
742
+ attachments.push(...parsed.attachments);
743
+ } else if (item?.type === "image_url") {
744
+ imageBlocks.push(item);
745
+ }
746
+ }
747
+ }
748
+
749
+ const rawPrimaryText = textParts.join("\n\n").trim();
750
+ const fallbackText = imageBlocks.length
751
+ ? "[Image(s) attached]"
752
+ : attachments.length
753
+ ? "[Text attachment(s) attached]"
754
+ : "";
755
+ const primaryText = rawPrimaryText || fallbackText;
756
+
757
+ const { head: inlineTextHead, tail: overflowText } = splitTextByTokenLimit(
758
+ primaryText,
759
+ INLINE_USER_PROMPT_TOKENS
760
+ );
761
+
762
+ if (overflowText.trim()) {
763
+ registerTextResource(state, {
764
+ kind: "prompt",
765
+ name: "Current user prompt continuation",
766
+ text: overflowText,
767
+ });
768
+ }
769
+
770
+ for (const attachment of attachments) {
771
+ registerTextResource(state, {
772
+ kind: "attachment",
773
+ name: attachment.name,
774
+ text: attachment.content,
775
+ });
776
+ }
777
+
778
+ let inlineImages = imageBlocks;
779
+ if (imageBlocks.length > MAX_INLINE_IMAGES) {
780
+ inlineImages = imageBlocks.slice(0, MAX_INLINE_IMAGES);
781
+ registerImageResource(state, {
782
+ name: "Current user images",
783
+ images: imageBlocks,
784
+ inlineImageCount: inlineImages.length,
785
+ });
786
+ }
787
+
788
+ const stagingNotes = [];
789
+ if (overflowText.trim()) {
790
+ stagingNotes.push(`Only the first ${INLINE_USER_PROMPT_TOKENS} tokens of the current prompt are inline.`);
791
+ }
792
+ if (attachments.length) {
793
+ stagingNotes.push(`${attachments.length} text attachment(s) were staged separately.`);
794
+ }
795
+ if (imageBlocks.length > inlineImages.length) {
796
+ stagingNotes.push(`${imageBlocks.length - inlineImages.length} additional image(s) were staged separately.`);
797
+ }
798
+
799
+ const inlineText = [
800
+ inlineTextHead.trim() || fallbackText,
801
+ stagingNotes.length
802
+ ? `[Context note: ${stagingNotes.join(" ")} See the staged context guide for resource IDs.]`
803
+ : null,
804
+ ]
805
+ .filter(Boolean)
806
+ .join("\n\n");
807
+
808
+ const userMessages = [];
809
+ if (Array.isArray(userMessage) || inlineImages.length) {
810
+ userMessages.push({
811
+ role: "user",
812
+ content: [
813
+ { type: "text", text: inlineText || "[Image(s) attached]" },
814
+ ...inlineImages,
815
+ ],
816
+ });
817
+ } else if (inlineText) {
818
+ userMessages.push({ role: "user", content: inlineText });
819
+ }
820
+
821
+ const contextMessages = state.resources.length
822
+ ? [
823
+ {
824
+ role: "system",
825
+ content: [
826
+ "Large prompt context was staged into smaller pieces.",
827
+ "Those staged resources are available context, not missing text.",
828
+ "Start with the inline user message, then use tools to inspect omitted prompt text, attachments, history, or images.",
829
+ "Use `list_prompt_resources` for the full manifest.",
830
+ "Use `read_prompt_chunk` for exact text.",
831
+ state.resources.some((resource) => resource.kind === "images")
832
+ ? "Use `load_prompt_images` to inspect omitted images in smaller batches."
833
+ : null,
834
+ "After reading multiple chunks, use `write_notes` to keep a compact working memory.",
835
+ "",
836
+ "Staged resources:",
837
+ ...state.resources.map(formatResourceManifestLine),
838
+ ]
839
+ .filter(Boolean)
840
+ .join("\n"),
841
+ },
842
+ ]
843
+ : [];
844
+
845
+ return { contextMessages, userMessages };
846
+ }
847
+
848
+ function buildMemorySystemMessages(memories = [], sessionName = "") {
849
+ const messages = [];
850
+ if (sessionName) {
851
+ messages.push({
852
+ role: "system",
853
+ content: `Current session name: "${sessionName}". This is hidden metadata and may help with continuity if the user references the chat title.`,
854
+ });
855
+ }
856
+ if (memories.length) {
857
+ messages.push({
858
+ role: "system",
859
+ content: [
860
+ "Persistent memories from earlier chats:",
861
+ ...memories.map((memory, index) => `${index + 1}. ${memory.content}`),
862
+ "Treat these as concise background notes. If one is outdated or wrong, prefer the user's current message.",
863
+ ].join("\n"),
864
+ });
865
+ }
866
+ return messages;
867
+ }
868
+
869
+ function buildBasePromptMessages({ sessionId, history, userMessage, memories = [], sessionName = "", systemPrompt = "" }) {
870
+ const state = createPromptState(sessionId);
871
+ const normalizedHistory = history.map(normalizeMessage).filter(Boolean);
872
+ const { summaryMessages, recentMessages } = prepareHistoryContext(normalizedHistory, state);
873
+ const { contextMessages, userMessages } = prepareCurrentUserContext(userMessage, state);
874
+
875
+ return [
876
+ { role: "system", content: systemPrompt || SYSTEM_PROMPT },
877
+ ...buildMemorySystemMessages(memories, sessionName),
878
+ ...summaryMessages,
879
+ ...recentMessages,
880
+ ...contextMessages,
881
+ ...userMessages,
882
+ ];
883
+ }
884
+
885
+ class RetryableRateLimitError extends Error {
886
+ constructor(message, retryAfterMs, internalMessage = null) {
887
+ super(message);
888
+ this.name = "RetryableRateLimitError";
889
+ this.retryAfterMs = retryAfterMs;
890
+ this.internalMessage = internalMessage || message;
891
+ this.publicMessage = "The model provider is temporarily rate limited. Retrying automatically.";
892
+ }
893
+ }
894
+
895
+ class UpstreamProviderError extends Error {
896
+ constructor(publicMessage, internalMessage = null) {
897
+ super(publicMessage);
898
+ this.name = "UpstreamProviderError";
899
+ this.publicMessage = publicMessage;
900
+ this.internalMessage = internalMessage || publicMessage;
901
+ }
902
+ }
903
+
904
+ function getErrorText(errorPayload) {
905
+ if (typeof errorPayload === "string") {
906
+ let text = errorPayload.trim();
907
+
908
+ if (text.startsWith("[ERROR]")) {
909
+ text = text.slice("[ERROR]".length).trim();
910
+ }
911
+
912
+ if (
913
+ (text.startsWith("\"") && text.endsWith("\"")) ||
914
+ (text.startsWith("'") && text.endsWith("'"))
915
+ ) {
916
+ try {
917
+ const parsed = JSON.parse(text);
918
+ if (typeof parsed === "string") {
919
+ text = parsed;
920
+ }
921
+ } catch {
922
+ // Keep the original text if it is not valid JSON.
923
+ }
924
+ }
925
+
926
+ return text;
927
+ }
928
+ if (!errorPayload) return "";
929
+ if (typeof errorPayload.message === "string") return errorPayload.message;
930
+ try {
931
+ return JSON.stringify(errorPayload);
932
+ } catch {
933
+ return String(errorPayload);
934
+ }
935
+ }
936
+
937
+ function isRateLimitError(errorPayload) {
938
+ const text = getErrorText(errorPayload).toLowerCase();
939
+ const code = String(errorPayload?.code || errorPayload?.error?.code || "").toLowerCase();
940
+ const type = String(errorPayload?.type || errorPayload?.error?.type || "").toLowerCase();
941
+ const status = Number(errorPayload?.status || errorPayload?.error?.status || 0);
942
+
943
+ return (
944
+ status === 429 ||
945
+ code === "rate_limit_exceeded" ||
946
+ type === "tokens" ||
947
+ text.includes("rate limit") ||
948
+ text.includes("rate limit reached") ||
949
+ text.includes("rate_limit_exceeded") ||
950
+ text.includes("upstream provider error (429)") ||
951
+ text.includes("(429)") ||
952
+ text.includes(" 429")
953
+ );
954
+ }
955
+
956
+ function isEmbeddedProviderErrorText(text) {
957
+ const normalized = getErrorText(text).toLowerCase();
958
+ return (
959
+ normalized.startsWith("upstream provider error") ||
960
+ normalized.startsWith("provider error") ||
961
+ normalized.includes("upstream provider error") ||
962
+ normalized.includes("rate_limit_exceeded") ||
963
+ normalized.includes("rate limit reached")
964
+ );
965
+ }
966
+
967
+ function extractRetryAfterMs(errorPayload) {
968
+ const text = getErrorText(errorPayload);
969
+ const directValue = Number(
970
+ errorPayload?.retryAfterMs ??
971
+ errorPayload?.retry_after_ms ??
972
+ errorPayload?.retry_after ??
973
+ errorPayload?.error?.retryAfterMs ??
974
+ errorPayload?.error?.retry_after_ms ??
975
+ errorPayload?.error?.retry_after
976
+ );
977
+
978
+ if (Number.isFinite(directValue) && directValue > 0) {
979
+ return directValue > 100 ? directValue : directValue * 1000;
980
+ }
981
+
982
+ const secondsMatch =
983
+ text.match(/try again in\s*([\d.]+)\s*s/i) ||
984
+ text.match(/retry after\s*([\d.]+)\s*s/i) ||
985
+ text.match(/in\s*([\d.]+)\s*seconds?/i);
986
+ if (secondsMatch) {
987
+ return Math.ceil(Number(secondsMatch[1]) * 1000);
988
+ }
989
+
990
+ const millisecondsMatch = text.match(/retry after\s*([\d.]+)\s*ms/i);
991
+ if (millisecondsMatch) {
992
+ return Math.ceil(Number(millisecondsMatch[1]));
993
+ }
994
+
995
+ return null;
996
+ }
997
+
998
+ function createAbortError() {
999
+ const err = new Error("AbortError");
1000
+ err.name = "AbortError";
1001
+ return err;
1002
+ }
1003
+
1004
+ async function sleepWithAbort(ms, abortSignal) {
1005
+ if (!ms || ms <= 0) return;
1006
+ if (abortSignal?.aborted) throw createAbortError();
1007
+
1008
+ await new Promise((resolve, reject) => {
1009
+ const timer = setTimeout(() => {
1010
+ abortSignal?.removeEventListener("abort", onAbort);
1011
+ resolve();
1012
+ }, ms);
1013
+
1014
+ const onAbort = () => {
1015
+ clearTimeout(timer);
1016
+ abortSignal?.removeEventListener("abort", onAbort);
1017
+ reject(createAbortError());
1018
+ };
1019
+
1020
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
1021
+ });
1022
+ }
1023
+
1024
+ function getRetryDelayMs(error, retryIndex) {
1025
+ const hintedDelay = Number(error?.retryAfterMs);
1026
+ if (Number.isFinite(hintedDelay) && hintedDelay > 0) {
1027
+ return Math.min(MAX_UPSTREAM_RETRY_MS, hintedDelay + UPSTREAM_RETRY_BUFFER_MS);
1028
+ }
1029
+
1030
+ return Math.min(
1031
+ MAX_UPSTREAM_RETRY_MS,
1032
+ DEFAULT_UPSTREAM_RETRY_MS * Math.max(1, retryIndex + 1)
1033
+ );
1034
+ }
1035
+
1036
+ function getPublicErrorMessage(err) {
1037
+ if (!err) return "The request failed.";
1038
+ if (err.name === "RetryableRateLimitError") {
1039
+ return "The model provider is temporarily rate limited. Please try again in a few seconds.";
1040
+ }
1041
+ if (err.publicMessage) return err.publicMessage;
1042
+ return String(err);
1043
+ }
1044
+
1045
+ async function websocketChatStreamWithRetry(body, headers, onToken, abortSignal) {
1046
+ for (let retryIndex = 0; ; retryIndex++) {
1047
+ try {
1048
+ return await websocketChatStream(body, headers, onToken, abortSignal);
1049
+ } catch (err) {
1050
+ if (err?.name === "AbortError") throw err;
1051
+
1052
+ const retryable = err?.name === "RetryableRateLimitError";
1053
+ if (!retryable || retryIndex >= MAX_UPSTREAM_RATE_LIMIT_RETRIES) {
1054
+ throw err;
1055
+ }
1056
+
1057
+ const waitMs = getRetryDelayMs(err, retryIndex);
1058
+ console.warn(
1059
+ `[streamChat] Upstream rate limited, retrying in ${waitMs}ms (${retryIndex + 1}/${MAX_UPSTREAM_RATE_LIMIT_RETRIES})`
1060
+ );
1061
+ await sleepWithAbort(waitMs, abortSignal);
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ function serializeToolCalls(toolCallBuffer) {
1067
+ return [...toolCallBuffer.values()]
1068
+ .filter((toolCall) => toolCall?.name)
1069
+ .map((toolCall) => ({
1070
+ id: toolCall.id || `call_${crypto.randomUUID()}`,
1071
+ type: "function",
1072
+ function: { name: toolCall.name, arguments: toolCall.arguments },
1073
+ }));
1074
+ }
1075
+
1076
  export async function websocketChatStream(body, headers, onToken, abortSignal) {
1077
  const ws = await getSafeWebSocket();
1078
  const currentRequestId = ++requestIdCounter;
1079
+ const allowedToolNames = getAllowedToolNames(body?.tools);
1080
  const safeParse = (str) => {
1081
  try { return JSON.parse(str.startsWith("data: ") ? str.slice(6) : str); } catch { return null; }
1082
  };
 
1090
  if (!finished) {
1091
  finished = true;
1092
  cleanup();
1093
+ const toolCalls = serializeToolCalls(toolCallBuffer);
 
 
 
 
1094
  resolve({ assistantText, toolCalls });
1095
  }
1096
  }, 120000);
 
1111
  if (!payload) return;
1112
 
1113
  if (payload.error && !payload.choices) {
1114
+ if (!finished) {
1115
+ finished = true;
1116
+ cleanup();
1117
+
1118
+ const errorText = getErrorText(payload.error);
1119
+ const hasPartialOutput = assistantText.length > 0 || toolCallBuffer.size > 0;
1120
+
1121
+ if (!hasPartialOutput && isRateLimitError(payload.error)) {
1122
+ reject(
1123
+ new RetryableRateLimitError(
1124
+ "The model provider is temporarily rate limited.",
1125
+ extractRetryAfterMs(payload.error),
1126
+ errorText
1127
+ )
1128
+ );
1129
+ } else {
1130
+ reject(
1131
+ new UpstreamProviderError(
1132
+ hasPartialOutput
1133
+ ? "The model provider interrupted the response. Please try again."
1134
+ : "The model provider returned an error. Please try again.",
1135
+ errorText
1136
+ )
1137
+ );
1138
+ }
1139
+ }
1140
  return;
1141
  }
1142
 
1143
  const delta = payload.choices?.[0]?.delta;
1144
+ if (delta?.content) {
1145
+ const contentText = String(delta.content);
1146
+
1147
+ if (isEmbeddedProviderErrorText(contentText)) {
1148
+ if (!finished) {
1149
+ finished = true;
1150
+ cleanup();
1151
+
1152
+ const errorText = getErrorText(contentText);
1153
+ const hasPartialOutput = assistantText.trim().length > 0 || toolCallBuffer.size > 0;
1154
+
1155
+ if (!hasPartialOutput && isRateLimitError(errorText)) {
1156
+ reject(
1157
+ new RetryableRateLimitError(
1158
+ "The model provider is temporarily rate limited.",
1159
+ extractRetryAfterMs(errorText),
1160
+ errorText
1161
+ )
1162
+ );
1163
+ } else {
1164
+ reject(
1165
+ new UpstreamProviderError(
1166
+ hasPartialOutput
1167
+ ? "The model provider interrupted the response. Please try again."
1168
+ : "The model provider returned an error. Please try again.",
1169
+ errorText
1170
+ )
1171
+ );
1172
+ }
1173
+ }
1174
+ return;
1175
+ }
1176
+
1177
+ assistantText += contentText;
1178
+ if (onToken) onToken(contentText);
1179
+ }
1180
 
1181
  if (delta?.tool_calls) {
1182
  for (const call of delta.tool_calls) {
1183
  const entry = toolCallBuffer.get(call.index) ?? { arguments: "" };
1184
  if (call.id) entry.id = call.id;
1185
+ if (call.function?.name) {
1186
+ entry.name = sanitizeToolName(call.function.name, allowedToolNames);
1187
+ }
1188
  if (call.function?.arguments) entry.arguments += call.function.arguments;
1189
  toolCallBuffer.set(call.index, entry);
1190
  }
 
1193
  if (payload.choices?.[0]?.finish_reason && !finished) {
1194
  finished = true;
1195
  cleanup();
1196
+ const toolCalls = serializeToolCalls(toolCallBuffer);
 
 
 
 
1197
  resolve({ assistantText, toolCalls });
1198
  }
1199
  };
 
1232
  history = [],
1233
  userMessage,
1234
  tools,
1235
+ owner = null,
1236
+ sessionName = "",
1237
  accessToken,
1238
  clientId,
1239
+ webSearchLimit = null,
1240
  onToken = () => {},
1241
  onDone = () => {},
1242
  onError = () => {},
1243
  onToolCall = () => {},
1244
  onNewAsset = () => {},
1245
+ onDraftEdit = () => {},
1246
  abortSignal,
1247
  }) {
 
1248
  const enabledTools = buildToolList(tools);
1249
+ const [memories, systemPrompt] = await Promise.all([
1250
+ owner ? memoryStore.list(owner).catch(() => []) : [],
1251
+ owner?.type === "user"
1252
+ ? systemPromptStore.getResolvedPrompt(owner.id)
1253
+ : systemPromptStore.getDefaultPrompt(),
1254
+ ]);
1255
+ const baseMessages = buildBasePromptMessages({
1256
+ sessionId,
1257
+ history,
1258
+ userMessage,
1259
+ memories,
1260
+ sessionName,
1261
+ systemPrompt,
1262
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1263
 
1264
  const headers = {
1265
  ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
 
1267
  };
1268
 
1269
  try {
1270
+ let assistantText = "";
1271
+ let agentStep = 0;
1272
+ let finished = false;
1273
+ const workingMessages = [];
1274
+ const allToolCalls = [];
1275
+ const draftState = { text: "" };
1276
+ const responseEdits = [];
1277
+ const responseSegments = [];
1278
+
1279
+ while (!finished && agentStep < MAX_AGENT_STEPS) {
1280
+ const effectiveMessages = buildModelMessages(baseMessages, workingMessages, sessionId);
1281
+ const body = {
1282
+ model: model || "lightning",
1283
+ messages: effectiveMessages,
1284
+ tools: enabledTools.length ? enabledTools : undefined,
1285
+ stream: true,
1286
+ };
1287
 
1288
+ const { assistantText: stepText, toolCalls } = await websocketChatStreamWithRetry(
1289
+ body,
1290
+ headers,
1291
+ onToken,
1292
+ abortSignal
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1293
  );
1294
 
1295
+ if (stepText) {
1296
+ responseSegments.push({ type: "text", text: stepText });
1297
+ }
1298
+ assistantText += stepText;
1299
+ draftState.text = assistantText;
1300
+
1301
+ if (toolCalls.length > 0) {
1302
+ allToolCalls.push(...toolCalls);
1303
+ responseSegments.push(
1304
+ ...toolCalls.map((call) => ({
1305
+ type: "tool_call",
1306
+ callId: call.id,
1307
+ }))
1308
+ );
1309
+ workingMessages.push({
1310
+ role: "assistant",
1311
+ content: stepText || "",
1312
+ tool_calls: toolCalls,
1313
+ });
1314
 
1315
+ const { nextMessages } = await processToolCalls({
1316
+ sessionId,
1317
+ toolCalls,
1318
+ tools,
1319
+ owner,
1320
+ accessToken,
1321
+ clientId,
1322
+ webSearchLimit,
1323
+ abortSignal,
1324
+ draftState,
1325
+ onToolCall,
1326
+ onNewAsset,
1327
+ onDraftEdit(edit, text) {
1328
+ responseEdits.push(edit);
1329
+ onDraftEdit(edit, text);
1330
+ },
1331
  });
1332
+ assistantText = draftState.text || assistantText;
1333
+ workingMessages.push(...nextMessages);
1334
+
1335
+ agentStep++;
1336
+ continue;
1337
+ } else {
1338
+ if (stepText) {
1339
+ workingMessages.push({ role: "assistant", content: stepText });
1340
+ }
1341
+ finished = true;
1342
  }
1343
+ }
1344
 
1345
+ if (!finished) {
1346
+ const finalMessages = [
1347
+ ...buildModelMessages(baseMessages, workingMessages, sessionId),
1348
  {
1349
+ role: "system",
1350
+ content: "Tool-use budget is exhausted for this response. Do not call tools. Answer directly using the information already gathered. If something is still missing, briefly say what is missing without calling tools.",
 
1351
  },
1352
+ ];
 
1353
 
1354
+ const { assistantText: finalStepText } = await websocketChatStreamWithRetry(
1355
+ {
1356
+ model: model || "lightning",
1357
+ messages: finalMessages,
1358
+ stream: true,
1359
+ },
1360
+ headers,
1361
+ onToken,
1362
+ abortSignal
1363
+ );
1364
 
1365
+ if (finalStepText) {
1366
+ responseSegments.push({ type: "text", text: finalStepText });
1367
+ assistantText += finalStepText;
1368
+ draftState.text = assistantText;
1369
+ workingMessages.push({ role: "assistant", content: finalStepText });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1370
  }
1371
+ finished = true;
1372
+ }
1373
+
1374
+ if (!assistantText.trim()) {
1375
+ assistantText = "I wasn’t able to finish that response cleanly. Please try again.";
1376
  }
1377
 
1378
  const sessionName = extractSessionName(assistantText);
1379
 
1380
  if (typeof onDone === "function") {
1381
+ onDone(assistantText, allToolCalls, false, sessionName, responseEdits, responseSegments);
1382
  }
1383
 
1384
+ clearPromptState(sessionId);
1385
 
1386
+ } catch (err) {
1387
+ console.error("streamChat error:", err?.internalMessage || err);
1388
+ clearPromptState(sessionId);
1389
+ if (err.name === "AbortError" || err.message === "AbortError") {
1390
+ if (typeof onDone === "function") onDone(null, null, true, null);
 
 
 
 
1391
  } else {
1392
+ if (typeof onError === "function") onError(getPublicErrorMessage(err));
 
 
 
1393
  }
1394
  }
1395
  }
 
1399
  function normalizeMessage(msg) {
1400
  if (!VALID_ROLES.has(msg.role)) return null;
1401
 
1402
+ if (msg.role === "assistant" && (msg.tool_calls || msg.toolCalls)) {
1403
+ const normalizedToolCalls = (msg.tool_calls || normalizeStoredToolCalls(msg.toolCalls))
1404
+ .map((call) => ({
1405
+ ...call,
1406
+ function: {
1407
+ ...call.function,
1408
+ name: sanitizeToolName(call.function?.name || call.name || "unknown_tool"),
1409
+ },
1410
+ }))
1411
+ .filter((call) => call.function?.name);
1412
+
1413
+ return {
1414
+ role: "assistant",
1415
+ content: msg.content ?? "",
1416
+ ...(normalizedToolCalls.length ? { tool_calls: normalizedToolCalls } : {}),
1417
+ };
1418
  }
1419
  if (Array.isArray(msg.content)) {
1420
  // If the array contains images, preserve the full array format
 
1433
  }
1434
 
1435
  function buildToolList(tools) {
1436
+ const config = tools || {};
1437
  const list = [];
1438
+ list.push({
1439
+ type: "function",
1440
+ function: {
1441
+ name: "list_prompt_resources",
1442
+ description: "List the staged prompt resources, including omitted prompt text, attachments, images, and condensed history",
1443
+ parameters: {
1444
+ type: "object",
1445
+ properties: {},
1446
+ },
1447
+ },
1448
+ });
1449
+
1450
+ list.push({
1451
+ type: "function",
1452
+ function: {
1453
+ name: "read_prompt_chunk",
1454
+ description: "Read an exact text chunk from a staged prompt resource",
1455
+ parameters: {
1456
+ type: "object",
1457
+ properties: {
1458
+ resource_id: { type: "string", description: "The resource id from list_prompt_resources" },
1459
+ chunk_index: { type: "number", description: "Zero-based chunk index" },
1460
+ },
1461
+ required: ["resource_id", "chunk_index"]
1462
+ },
1463
+ },
1464
+ });
1465
+
1466
+ list.push({
1467
+ type: "function",
1468
+ function: {
1469
+ name: "load_prompt_images",
1470
+ description: "Load staged images into the next model step in a smaller batch",
1471
+ parameters: {
1472
+ type: "object",
1473
+ properties: {
1474
+ resource_id: { type: "string", description: "The staged image resource id" },
1475
+ indexes: { type: "array", items: { type: "number" }, description: "Zero-based image indexes to load" },
1476
+ start_index: { type: "number", description: "Optional zero-based starting image index when indexes is omitted" },
1477
+ count: { type: "number", description: "Optional number of images to load when indexes is omitted" },
1478
+ },
1479
+ required: ["resource_id"]
1480
+ },
1481
+ },
1482
+ });
1483
+
1484
+ list.push({
1485
+ type: "function",
1486
+ function: {
1487
+ name: "write_notes",
1488
+ description: "Write notes to memory for later reasoning",
1489
+ parameters: { type: "object", properties: { note: { type: "string" } }, required: ["note"] }
1490
+ }
1491
+ });
1492
+ list.push({
1493
+ type: "function",
1494
+ function: {
1495
+ name: "list_app_doc_sections",
1496
+ description: "List the available sections of the app documentation so you can choose one relevant section to read.",
1497
+ parameters: {
1498
+ type: "object",
1499
+ properties: {},
1500
+ },
1501
+ },
1502
+ });
1503
+ list.push({
1504
+ type: "function",
1505
+ function: {
1506
+ name: "read_app_doc_section",
1507
+ description: "Read one specific app documentation section by section id or exact section title.",
1508
+ parameters: {
1509
+ type: "object",
1510
+ properties: {
1511
+ section_id: { type: "string", description: "The section id or exact title from list_app_doc_sections." },
1512
+ },
1513
+ required: ["section_id"],
1514
+ },
1515
+ },
1516
+ });
1517
+ list.push({
1518
+ type: "function",
1519
+ function: {
1520
+ name: "list_memories",
1521
+ description: "List the currently saved persistent memories for this user.",
1522
+ parameters: {
1523
+ type: "object",
1524
+ properties: {},
1525
+ },
1526
+ },
1527
+ });
1528
+ list.push({
1529
+ type: "function",
1530
+ function: {
1531
+ name: "save_memory",
1532
+ description: "Save a short persistent memory that may help in future chats. Keep it brief and only use this for durable user preferences or facts.",
1533
+ parameters: {
1534
+ type: "object",
1535
+ properties: {
1536
+ content: { type: "string", description: "A short durable memory, ideally one sentence or shorter." },
1537
+ },
1538
+ required: ["content"],
1539
+ },
1540
+ },
1541
+ });
1542
+ list.push({
1543
+ type: "function",
1544
+ function: {
1545
+ name: "delete_memory",
1546
+ description: "Delete a previously saved persistent memory when it is outdated or incorrect.",
1547
+ parameters: {
1548
+ type: "object",
1549
+ properties: {
1550
+ memory_id: { type: "string", description: "The memory id to delete." },
1551
+ },
1552
+ required: ["memory_id"],
1553
+ },
1554
+ },
1555
+ });
1556
+ list.push({
1557
+ type: "function",
1558
+ function: {
1559
+ name: "edit_response_draft",
1560
+ description: "Revise text that was already streamed to the user. Use this to correct or remove visible text that has already appeared.",
1561
+ parameters: {
1562
+ type: "object",
1563
+ properties: {
1564
+ operation: {
1565
+ type: "string",
1566
+ enum: ["replace_all", "replace_last_match", "delete_last_match", "delete_last_chars", "append", "clear"],
1567
+ },
1568
+ text: { type: "string", description: "Replacement text for replace_all or text to append." },
1569
+ target_text: { type: "string", description: "Exact text to replace or delete for match-based operations." },
1570
+ replacement_text: { type: "string", description: "Replacement text for replace_last_match." },
1571
+ count: { type: "number", description: "Character count for delete_last_chars." },
1572
+ reason: { type: "string", description: "Brief reason for the visible edit." },
1573
+ },
1574
+ required: ["operation"],
1575
+ },
1576
+ },
1577
+ });
1578
+ if (config.webSearch) {
1579
  list.push({
1580
  type: "function",
1581
  function: {
 
1601
  },
1602
  });
1603
  }
1604
+ if (config.imageGen) {
1605
  list.push({
1606
  type: "function",
1607
  function: {
 
1619
  },
1620
  });
1621
  }
1622
+ if (config.videoGen) {
1623
  list.push({
1624
  type: "function",
1625
  function: {
 
1639
  },
1640
  });
1641
  }
1642
+ if (config.audioGen) {
1643
  list.push({
1644
  type: "function",
1645
  function: {
 
1656
  return list;
1657
  }
1658
 
1659
+ function normalizeRequestedImageIndexes(args, resource) {
1660
+ const explicitIndexes = Array.isArray(args.indexes)
1661
+ ? [...new Set(args.indexes.map((value) => Number(value)).filter(Number.isInteger))]
1662
+ : [];
1663
+
1664
+ const validExplicitIndexes = explicitIndexes.filter(
1665
+ (index) => index >= 0 && index < resource.imageCount
1666
+ );
1667
+ if (validExplicitIndexes.length) return validExplicitIndexes;
1668
+
1669
+ const requestedStart = Number.isInteger(Number(args.start_index))
1670
+ ? Number(args.start_index)
1671
+ : resource.inlineImageCount || 0;
1672
+ const requestedCount = Number.isInteger(Number(args.count))
1673
+ ? Number(args.count)
1674
+ : MAX_INLINE_IMAGES;
1675
+
1676
+ const indexes = [];
1677
+ for (let i = 0; i < requestedCount; i++) {
1678
+ const index = requestedStart + i;
1679
+ if (index >= 0 && index < resource.imageCount) {
1680
+ indexes.push(index);
1681
+ }
1682
+ }
1683
+
1684
+ return indexes;
1685
+ }
1686
+
1687
+ function applyResponseDraftEdit(currentText, args = {}) {
1688
+ const source = String(currentText || "");
1689
+ const operation = args.operation;
1690
+ switch (operation) {
1691
+ case "replace_all":
1692
+ return String(args.text || "");
1693
+ case "append":
1694
+ return source + String(args.text || "");
1695
+ case "clear":
1696
+ return "";
1697
+ case "replace_last_match": {
1698
+ const target = String(args.target_text || "");
1699
+ if (!target) return source;
1700
+ const idx = source.lastIndexOf(target);
1701
+ if (idx === -1) return source;
1702
+ return source.slice(0, idx) + String(args.replacement_text || "") + source.slice(idx + target.length);
1703
+ }
1704
+ case "delete_last_match": {
1705
+ const target = String(args.target_text || "");
1706
+ if (!target) return source;
1707
+ const idx = source.lastIndexOf(target);
1708
+ if (idx === -1) return source;
1709
+ return source.slice(0, idx) + source.slice(idx + target.length);
1710
+ }
1711
+ case "delete_last_chars": {
1712
+ const count = Math.max(0, Number(args.count) || 0);
1713
+ return source.slice(0, Math.max(0, source.length - count));
1714
+ }
1715
+ default:
1716
+ return source;
1717
+ }
1718
+ }
1719
+
1720
+ async function processToolCalls({
1721
+ sessionId,
1722
+ toolCalls,
1723
+ tools,
1724
+ owner,
1725
+ accessToken,
1726
+ clientId,
1727
+ webSearchLimit,
1728
+ abortSignal,
1729
+ draftState,
1730
+ onToolCall,
1731
+ onNewAsset,
1732
+ onDraftEdit,
1733
+ }) {
1734
+ const nextMessages = [];
1735
  const authHeaders = {};
1736
+ const allowedToolNames = getAllowedToolNames(buildToolList(tools));
1737
  if (accessToken) {
1738
  authHeaders["Authorization"] = `Bearer ${accessToken}`;
1739
  }
 
1744
  for (const call of toolCalls) {
1745
  let args;
1746
  try { args = JSON.parse(call.function.arguments || "{}"); } catch { args = {}; }
1747
+ const toolName = sanitizeToolName(call.function?.name, allowedToolNames);
1748
+ if (toolName) {
1749
+ call.function.name = toolName;
1750
+ }
1751
 
1752
+ onToolCall({ id: call.id, name: toolName || call.function?.name, state: "pending", args });
1753
 
1754
  let result = "Tool completed.";
1755
 
1756
  try {
1757
+ if (!toolName || !allowedToolNames.includes(toolName)) {
1758
+ result = `Invalid tool name "${call.function?.name || "unknown"}".`;
1759
+ }
1760
+
1761
+ else if (toolName === "list_prompt_resources") {
1762
+ const state = getPromptState(sessionId);
1763
+ result = JSON.stringify(buildPromptResourceManifest(state), null, 2);
1764
+ }
1765
+
1766
+ else if (toolName === "read_prompt_chunk") {
1767
+ const state = getPromptState(sessionId);
1768
+ const chunkIndex = Number.isInteger(Number(args.chunk_index))
1769
+ ? Number(args.chunk_index)
1770
+ : Number(args.chunk_id);
1771
+ const resourceId = args.resource_id || args.filename;
1772
+ const resource = findPromptResource(state, resourceId);
1773
+
1774
+ if (!resource) {
1775
+ result = `Prompt resource "${resourceId}" not found.`;
1776
+ } else if (resource.kind === "images") {
1777
+ result = `Resource "${resource.id}" contains images, not text. Use load_prompt_images instead.`;
1778
+ } else if (!Number.isInteger(chunkIndex) || chunkIndex < 0 || chunkIndex >= resource.chunkCount) {
1779
+ result = `Chunk ${chunkIndex} is out of range for resource "${resource.id}" (${resource.chunkCount} chunk(s)).`;
1780
+ } else {
1781
+ const chunk = resource.chunks[chunkIndex];
1782
+ result = [
1783
+ `Resource: ${resource.id}`,
1784
+ `Name: ${resource.name}`,
1785
+ `Kind: ${resource.kind}`,
1786
+ `Chunk: ${chunk.index + 1}/${resource.chunkCount}`,
1787
+ "",
1788
+ chunk.text,
1789
+ ].join("\n");
1790
+ }
1791
+ }
1792
+
1793
+ else if (toolName === "load_prompt_images") {
1794
+ const state = getPromptState(sessionId);
1795
+ const resource = findPromptResource(state, args.resource_id);
1796
+
1797
+ if (!resource) {
1798
+ result = `Prompt image resource "${args.resource_id}" not found.`;
1799
+ } else if (resource.kind !== "images") {
1800
+ result = `Resource "${resource.id}" is not an image resource.`;
1801
+ } else {
1802
+ const indexes = normalizeRequestedImageIndexes(args, resource);
1803
+ const selectedImages = indexes
1804
+ .map((index) => resource.images[index]?.block)
1805
+ .filter(Boolean);
1806
+
1807
+ if (!selectedImages.length) {
1808
+ result = `No images were loaded from "${resource.id}".`;
1809
+ } else {
1810
+ result = `Loaded image indexes ${indexes.join(", ")} from "${resource.id}".`;
1811
+ nextMessages.push({
1812
+ role: "tool",
1813
+ tool_call_id: call.id,
1814
+ content: result,
1815
+ });
1816
+ nextMessages.push({
1817
+ role: "user",
1818
+ content: [
1819
+ {
1820
+ type: "text",
1821
+ text: `Requested staged images from resource "${resource.id}" (${resource.name}), indexes ${indexes.join(", ")}.`,
1822
+ },
1823
+ ...selectedImages,
1824
+ ],
1825
+ });
1826
+ onToolCall({ id: call.id, name: toolName, state: "resolved", result });
1827
+ continue;
1828
+ }
1829
+ }
1830
+ }
1831
+
1832
+ else if (toolName === "write_notes") {
1833
+ appendAssistantNote(sessionId, args.note);
1834
+ result = "Note stored for the rest of this response.";
1835
+ }
1836
+
1837
+ else if (toolName === "list_app_doc_sections") {
1838
+ const sections = await listAppDocSections();
1839
+ result = JSON.stringify(sections, null, 2);
1840
+ }
1841
+
1842
+ else if (toolName === "read_app_doc_section") {
1843
+ const section = await readAppDocSection(args.section_id);
1844
+ result = section
1845
+ ? [`Section: ${section.title}`, `Section ID: ${section.id}`, "", section.content].join("\n")
1846
+ : `Documentation section "${args.section_id}" was not found.`;
1847
+ }
1848
+
1849
+ else if (toolName === "list_memories") {
1850
+ const memories = owner ? await memoryStore.list(owner) : [];
1851
+ result = JSON.stringify(memories, null, 2);
1852
+ }
1853
+
1854
+ else if (toolName === "save_memory") {
1855
+ const memory = owner
1856
+ ? await memoryStore.create(owner, {
1857
+ content: args.content,
1858
+ sessionId,
1859
+ source: "assistant",
1860
+ })
1861
+ : null;
1862
+ result = memory
1863
+ ? `Saved memory ${memory.id}: ${memory.content}`
1864
+ : "Memory was empty or could not be saved.";
1865
+ }
1866
+
1867
+ else if (toolName === "delete_memory") {
1868
+ const ok = owner ? await memoryStore.delete(owner, args.memory_id) : false;
1869
+ result = ok ? `Deleted memory ${args.memory_id}.` : `Memory ${args.memory_id} was not found.`;
1870
  }
1871
 
1872
+ else if (toolName === "edit_response_draft") {
1873
+ const before = draftState?.text || "";
1874
+ const after = applyResponseDraftEdit(before, args);
1875
+ if (draftState) draftState.text = after;
1876
+ const edit = {
1877
+ operation: args.operation,
1878
+ reason: String(args.reason || "").trim() || "Model revised its draft.",
1879
+ before,
1880
+ after,
1881
+ timestamp: Date.now(),
1882
+ };
1883
+ if (before !== after) {
1884
+ onDraftEdit(edit, after);
1885
+ result = [
1886
+ `Draft updated. The visible response now has ${after.length} character(s).`,
1887
+ "Visible draft:",
1888
+ (after || "[empty]").slice(-4000),
1889
+ ].join("\n\n");
1890
+ } else {
1891
+ result = "Draft edit made no visible change.";
1892
+ }
1893
+ }
1894
+
1895
+ else if (toolName === "ollama_search") {
1896
+ if (webSearchLimit?.key && Number.isFinite(webSearchLimit.limit)) {
1897
+ const usage = await consumeWebSearchUsage(webSearchLimit.key, webSearchLimit.limit);
1898
+ if (!usage.allowed) {
1899
+ result = `Web search limit reached for today. Free accounts can use ${usage.limit} web searches per day. Try again on ${usage.window} after the daily reset or upgrade to a paid plan.`;
1900
+ } else {
1901
+ result = await gradioSearch(args.query);
1902
+ }
1903
+ } else {
1904
+ result = await gradioSearch(args.query);
1905
+ }
1906
+ }
1907
+
1908
+ else if (toolName === "read_web_page") {
1909
  const { convert } = await import("html-to-text");
1910
  const res = await fetch(args.url, { signal: abortSignal });
1911
  if (!res.ok) {
 
1920
  }
1921
  }
1922
 
1923
+ else if (toolName === "generate_image") {
1924
  const body = { prompt: args.prompt };
1925
  if (args.mode) body.mode = args.mode;
1926
  if (args.image_urls?.length) body.image_urls = args.image_urls;
 
1934
  if (res.ok) {
1935
  const buf = await res.arrayBuffer();
1936
  const ct = res.headers.get("content-type") || "image/png";
1937
+ const stored = await mediaStore.storeBuffer(owner, {
1938
+ name: `generated-image-${Date.now()}.${ct.includes("svg") ? "svg" : ct.split("/")[1] || "png"}`,
1939
+ mimeType: ct,
1940
+ buffer: Buffer.from(buf),
1941
+ sessionId,
1942
+ source: "assistant_generated",
1943
+ kind: "image",
1944
+ });
1945
+ onNewAsset({ id: stored.id, role: "image", mimeType: ct, name: stored.name });
1946
  result = "Image generated successfully and shown to the user.";
1947
  } else if (res.status == 402) {
1948
  result = "An upgraded plan is required for higher limits.";
 
1953
  }
1954
  }
1955
 
1956
+ else if (toolName === "generate_video") {
1957
  const body = { prompt: args.prompt };
1958
  if (args.ratio) body.ratio = args.ratio;
1959
  if (args.mode) body.mode = args.mode;
 
1968
  });
1969
  if (res.ok) {
1970
  const buf = await res.arrayBuffer();
1971
+ const contentType = res.headers.get("content-type") || "video/mp4";
1972
+ const stored = await mediaStore.storeBuffer(owner, {
1973
+ name: `generated-video-${Date.now()}.${contentType.split("/")[1] || "mp4"}`,
1974
+ mimeType: contentType,
1975
+ buffer: Buffer.from(buf),
1976
+ sessionId,
1977
+ source: "assistant_generated",
1978
+ kind: "video",
1979
+ });
1980
+ onNewAsset({ id: stored.id, role: "video", mimeType: contentType, name: stored.name });
1981
  result = "Video generated successfully and shown to the user.";
1982
  } else if (res.status == 402) {
1983
  result = "An upgraded plan is required for higher limits.";
 
1988
  }
1989
  }
1990
 
1991
+ else if (toolName === "generate_audio") {
1992
  const res = await fetch(`${LIGHTNING_BASE}/gen/sfx`, {
1993
  method: "POST",
1994
  headers: { "Content-Type": "application/json", ...authHeaders },
 
1997
  });
1998
  if (res.ok) {
1999
  const buf = await res.arrayBuffer();
2000
+ const contentType = res.headers.get("content-type") || "audio/mpeg";
2001
+ const stored = await mediaStore.storeBuffer(owner, {
2002
+ name: `generated-audio-${Date.now()}.${contentType.split("/")[1] || "mp3"}`,
2003
+ mimeType: contentType,
2004
+ buffer: Buffer.from(buf),
2005
+ sessionId,
2006
+ source: "assistant_generated",
2007
+ kind: "audio",
2008
+ });
2009
+ onNewAsset({ id: stored.id, role: "audio", mimeType: contentType, name: stored.name });
2010
  result = "Audio generated successfully and shown to the user.";
2011
  } else if (res.status == 429) {
2012
  result = "Too many requests. Try again later.";
 
2018
  result = `Tool error: ${String(err)}`;
2019
  }
2020
 
2021
+ onToolCall({ id: call.id, name: toolName || call.function?.name, state: "resolved", result });
2022
 
2023
+ nextMessages.push({
2024
  role: "tool",
2025
  tool_call_id: call.id,
2026
  content: typeof result === "string" ? result : JSON.stringify(result),
2027
  });
2028
  }
2029
 
2030
+ return { nextMessages, draftText: draftState?.text || "" };
2031
+ }
server/chatTrashStore.js ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from 'crypto';
2
+ import path from 'path';
3
+ import { loadEncryptedJson, saveEncryptedJson } from './cryptoUtils.js';
4
+
5
+ const DATA_ROOT = '/data/deleted_chats';
6
+ const INDEX_FILE = path.join(DATA_ROOT, 'index.json');
7
+ const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
8
+
9
+ const state = {
10
+ loaded: false,
11
+ index: {
12
+ deletedChats: {},
13
+ },
14
+ };
15
+
16
+ function nowIso() {
17
+ return new Date().toISOString();
18
+ }
19
+
20
+ function ensureOwner(owner) {
21
+ if (!owner?.type || !owner?.id) throw new Error('Invalid deleted chat owner');
22
+ return owner;
23
+ }
24
+
25
+ async function ensureLoaded() {
26
+ if (state.loaded) return;
27
+ const stored = await loadEncryptedJson(INDEX_FILE);
28
+ state.index = {
29
+ deletedChats: stored?.deletedChats || {},
30
+ };
31
+ state.loaded = true;
32
+ await thisStore.purgeExpired();
33
+ }
34
+
35
+ async function saveIndex() {
36
+ await saveEncryptedJson(INDEX_FILE, state.index);
37
+ }
38
+
39
+ function matchesOwner(record, owner) {
40
+ return record.ownerType === owner.type && record.ownerId === owner.id;
41
+ }
42
+
43
+ function sanitize(record) {
44
+ return {
45
+ id: record.id,
46
+ originalSessionId: record.originalSessionId,
47
+ name: record.name,
48
+ deletedAt: record.deletedAt,
49
+ purgeAt: record.purgeAt,
50
+ created: record.created,
51
+ };
52
+ }
53
+
54
+ const thisStore = {
55
+ async add(owner, sessionSnapshot) {
56
+ ensureOwner(owner);
57
+ await ensureLoaded();
58
+
59
+ const sessionId = sessionSnapshot?.id;
60
+ if (!sessionId) return null;
61
+
62
+ for (const record of Object.values(state.index.deletedChats)) {
63
+ if (
64
+ record.originalSessionId === sessionId &&
65
+ record.ownerType === owner.type &&
66
+ record.ownerId === owner.id
67
+ ) {
68
+ record.sessionSnapshot = sessionSnapshot;
69
+ record.name = sessionSnapshot.name || 'Deleted Chat';
70
+ record.created = sessionSnapshot.created || Date.now();
71
+ record.deletedAt = nowIso();
72
+ record.purgeAt = new Date(Date.now() + RETENTION_MS).toISOString();
73
+ await saveIndex();
74
+ return sanitize(record);
75
+ }
76
+ }
77
+
78
+ const deleted = {
79
+ id: crypto.randomUUID(),
80
+ originalSessionId: sessionId,
81
+ ownerType: owner.type,
82
+ ownerId: owner.id,
83
+ name: sessionSnapshot.name || 'Deleted Chat',
84
+ created: sessionSnapshot.created || Date.now(),
85
+ sessionSnapshot,
86
+ deletedAt: nowIso(),
87
+ purgeAt: new Date(Date.now() + RETENTION_MS).toISOString(),
88
+ };
89
+ state.index.deletedChats[deleted.id] = deleted;
90
+ await saveIndex();
91
+ return sanitize(deleted);
92
+ },
93
+
94
+ async list(owner) {
95
+ ensureOwner(owner);
96
+ await ensureLoaded();
97
+ return Object.values(state.index.deletedChats)
98
+ .filter((record) => matchesOwner(record, owner))
99
+ .sort((a, b) => new Date(b.deletedAt).getTime() - new Date(a.deletedAt).getTime())
100
+ .map(sanitize);
101
+ },
102
+
103
+ async restore(owner, ids) {
104
+ ensureOwner(owner);
105
+ await ensureLoaded();
106
+ const restored = [];
107
+ for (const id of ids || []) {
108
+ const record = state.index.deletedChats[id];
109
+ if (!record || !matchesOwner(record, owner)) continue;
110
+ restored.push(record.sessionSnapshot);
111
+ delete state.index.deletedChats[id];
112
+ }
113
+ if (restored.length) await saveIndex();
114
+ return restored;
115
+ },
116
+
117
+ async deleteForever(owner, ids) {
118
+ ensureOwner(owner);
119
+ await ensureLoaded();
120
+ const removed = [];
121
+ for (const id of ids || []) {
122
+ const record = state.index.deletedChats[id];
123
+ if (!record || !matchesOwner(record, owner)) continue;
124
+ delete state.index.deletedChats[id];
125
+ removed.push(id);
126
+ }
127
+ if (removed.length) await saveIndex();
128
+ return removed;
129
+ },
130
+
131
+ async purgeExpired() {
132
+ if (!state.loaded) return;
133
+ const now = Date.now();
134
+ let changed = false;
135
+ for (const [id, record] of Object.entries(state.index.deletedChats)) {
136
+ if (new Date(record.purgeAt).getTime() <= now) {
137
+ delete state.index.deletedChats[id];
138
+ changed = true;
139
+ }
140
+ }
141
+ if (changed) await saveIndex();
142
+ },
143
+ };
144
+
145
+ setInterval(() => {
146
+ thisStore.purgeExpired().catch((err) => console.error('chatTrashStore cleanup failed:', err));
147
+ }, 6 * 60 * 60 * 1000);
148
+
149
+ export const chatTrashStore = thisStore;
server/cryptoUtils.js CHANGED
@@ -2,61 +2,135 @@ import crypto from 'crypto';
2
  import fs from 'fs/promises';
3
  import path from 'path';
4
 
 
 
5
  const ALGORITHM = 'aes-256-gcm';
6
- const KEY_LENGTH = 32; // 256 bits
7
- const IV_LENGTH = 16; // 128 bits for GCM
8
- const AUTH_TAG_LENGTH = 16; // 128 bits
9
 
10
- // Derive key from environment variable
11
  function getKey() {
12
  const keyEnv = process.env.DATA_ENCRYPTION_KEY;
13
  if (!keyEnv) throw new Error('DATA_ENCRYPTION_KEY environment variable not set');
14
- // Use SHA-256 to derive a 32-byte key from the env var
15
- return crypto.createHash('sha256').update(keyEnv).digest();
16
  }
17
 
18
- export function encryptJson(data) {
 
 
 
 
 
19
  const key = getKey();
20
  const iv = crypto.randomBytes(IV_LENGTH);
21
- const cipher = crypto.createCipher(ALGORITHM, key);
22
- cipher.setAAD(Buffer.from('')); // Optional AAD
23
-
24
- let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'hex');
25
- encrypted += cipher.final('hex');
26
-
27
  const authTag = cipher.getAuthTag();
28
  return {
29
- iv: iv.toString('hex'),
 
 
30
  encrypted,
31
- authTag: authTag.toString('hex'),
32
  };
33
  }
34
 
35
- export function decryptJson(encryptedData) {
36
  const key = getKey();
37
- const { iv, encrypted, authTag } = encryptedData;
38
- const decipher = crypto.createDecipher(ALGORITHM, key);
39
- decipher.setAuthTag(Buffer.from(authTag, 'hex'));
40
- decipher.setAAD(Buffer.from('')); // Match AAD
 
 
 
 
 
 
 
 
 
 
41
 
42
- let decrypted = decipher.update(encrypted, 'hex', 'utf8');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  decrypted += decipher.final('utf8');
44
  return JSON.parse(decrypted);
45
  }
46
 
47
- export async function saveEncryptedJson(filePath, data) {
48
- const encrypted = encryptJson(data);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  await fs.mkdir(path.dirname(filePath), { recursive: true });
50
- await fs.writeFile(filePath, JSON.stringify(encrypted));
51
  }
52
 
53
- export async function loadEncryptedJson(filePath) {
54
  try {
55
  const content = await fs.readFile(filePath, 'utf8');
56
  const encrypted = JSON.parse(content);
57
- return decryptJson(encrypted);
58
  } catch (err) {
59
- if (err.code === 'ENOENT') return null; // File not found
60
  throw err;
61
  }
62
- }
 
2
  import fs from 'fs/promises';
3
  import path from 'path';
4
 
5
+ const JSON_FORMAT_VERSION = 2;
6
+ const BINARY_FORMAT_VERSION = 1;
7
  const ALGORITHM = 'aes-256-gcm';
8
+ const KEY_LENGTH = 32;
9
+ const IV_LENGTH = 12;
10
+ const AUTH_TAG_LENGTH = 16;
11
 
 
12
  function getKey() {
13
  const keyEnv = process.env.DATA_ENCRYPTION_KEY;
14
  if (!keyEnv) throw new Error('DATA_ENCRYPTION_KEY environment variable not set');
15
+ return crypto.createHash('sha256').update(keyEnv).digest().subarray(0, KEY_LENGTH);
 
16
  }
17
 
18
+ function normalizeAad(aad = '') {
19
+ if (Buffer.isBuffer(aad)) return aad;
20
+ return Buffer.from(String(aad || ''), 'utf8');
21
+ }
22
+
23
+ export function encryptBuffer(buffer, aad = '') {
24
  const key = getKey();
25
  const iv = crypto.randomBytes(IV_LENGTH);
26
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
27
+ const aadBuffer = normalizeAad(aad);
28
+ if (aadBuffer.length) cipher.setAAD(aadBuffer);
29
+ const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
 
 
30
  const authTag = cipher.getAuthTag();
31
  return {
32
+ version: BINARY_FORMAT_VERSION,
33
+ iv,
34
+ authTag,
35
  encrypted,
 
36
  };
37
  }
38
 
39
+ export function decryptBuffer(payload, aad = '') {
40
  const key = getKey();
41
+ const iv = Buffer.isBuffer(payload?.iv) ? payload.iv : Buffer.from(payload?.iv || '', 'hex');
42
+ const authTag = Buffer.isBuffer(payload?.authTag)
43
+ ? payload.authTag
44
+ : Buffer.from(payload?.authTag || '', 'hex');
45
+ const encrypted = Buffer.isBuffer(payload?.encrypted)
46
+ ? payload.encrypted
47
+ : Buffer.from(payload?.encrypted || '', 'hex');
48
+
49
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
50
+ const aadBuffer = normalizeAad(aad);
51
+ if (aadBuffer.length) decipher.setAAD(aadBuffer);
52
+ decipher.setAuthTag(authTag);
53
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]);
54
+ }
55
 
56
+ export function packEncryptedBuffer(payload) {
57
+ const header = Buffer.allocUnsafe(1 + 1 + payload.iv.length + payload.authTag.length);
58
+ header.writeUInt8(payload.version || BINARY_FORMAT_VERSION, 0);
59
+ header.writeUInt8(payload.iv.length, 1);
60
+ payload.iv.copy(header, 2);
61
+ payload.authTag.copy(header, 2 + payload.iv.length);
62
+ return Buffer.concat([header, payload.encrypted]);
63
+ }
64
+
65
+ export function unpackEncryptedBuffer(buffer) {
66
+ const version = buffer.readUInt8(0);
67
+ if (version !== BINARY_FORMAT_VERSION) {
68
+ throw new Error(`Unsupported encrypted buffer version: ${version}`);
69
+ }
70
+ const ivLength = buffer.readUInt8(1);
71
+ const ivStart = 2;
72
+ const ivEnd = ivStart + ivLength;
73
+ const tagEnd = ivEnd + AUTH_TAG_LENGTH;
74
+ return {
75
+ version,
76
+ iv: buffer.subarray(ivStart, ivEnd),
77
+ authTag: buffer.subarray(ivEnd, tagEnd),
78
+ encrypted: buffer.subarray(tagEnd),
79
+ };
80
+ }
81
+
82
+ export async function writeEncryptedFile(filePath, buffer, aad = '') {
83
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
84
+ const payload = encryptBuffer(buffer, aad);
85
+ await fs.writeFile(filePath, packEncryptedBuffer(payload));
86
+ }
87
+
88
+ export async function readEncryptedFile(filePath, aad = '') {
89
+ const packed = await fs.readFile(filePath);
90
+ return decryptBuffer(unpackEncryptedBuffer(packed), aad);
91
+ }
92
+
93
+ function legacyDecryptJson(encryptedData) {
94
+ const key = getKey();
95
+ const decipher = crypto.createDecipher(ALGORITHM, key);
96
+ decipher.setAuthTag(Buffer.from(encryptedData.authTag, 'hex'));
97
+ let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8');
98
  decrypted += decipher.final('utf8');
99
  return JSON.parse(decrypted);
100
  }
101
 
102
+ export function encryptJson(data, aad = '') {
103
+ const payload = encryptBuffer(Buffer.from(JSON.stringify(data), 'utf8'), aad);
104
+ return {
105
+ version: JSON_FORMAT_VERSION,
106
+ iv: payload.iv.toString('hex'),
107
+ authTag: payload.authTag.toString('hex'),
108
+ encrypted: payload.encrypted.toString('hex'),
109
+ };
110
+ }
111
+
112
+ export function decryptJson(encryptedData, aad = '') {
113
+ if (!encryptedData) return null;
114
+ if ((encryptedData.version || 0) >= JSON_FORMAT_VERSION) {
115
+ const decrypted = decryptBuffer(encryptedData, aad);
116
+ return JSON.parse(decrypted.toString('utf8'));
117
+ }
118
+ return legacyDecryptJson(encryptedData);
119
+ }
120
+
121
+ export async function saveEncryptedJson(filePath, data, aad = '') {
122
+ const encrypted = encryptJson(data, aad);
123
  await fs.mkdir(path.dirname(filePath), { recursive: true });
124
+ await fs.writeFile(filePath, JSON.stringify(encrypted, null, 2), 'utf8');
125
  }
126
 
127
+ export async function loadEncryptedJson(filePath, aad = '') {
128
  try {
129
  const content = await fs.readFile(filePath, 'utf8');
130
  const encrypted = JSON.parse(content);
131
+ return decryptJson(encrypted, aad);
132
  } catch (err) {
133
+ if (err.code === 'ENOENT') return null;
134
  throw err;
135
  }
136
+ }
server/handleFeedback.js ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'path';
2
+ import crypto from 'crypto';
3
+ import { saveEncryptedJson, loadEncryptedJson } from './cryptoUtils.js';
4
+
5
+ const DATA_DIR = '/data';
6
+ const TICKETS_FILE = path.join(DATA_DIR, 'feedback_tickets.json');
7
+ const FEEDBACK_AAD = 'feedback_tickets_v1';
8
+
9
+ const MAX_TITLE_LENGTH = 100;
10
+ const MAX_BODY_LENGTH = 10000;
11
+
12
+ function generateTicketId() {
13
+ return `tkt_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
14
+ }
15
+
16
+ function sanitizeString(value, maxLength) {
17
+ if (typeof value !== 'string') return '';
18
+ return value.trim().slice(0, maxLength);
19
+ }
20
+
21
+ async function loadTickets() {
22
+ const data = await loadEncryptedJson(TICKETS_FILE, FEEDBACK_AAD);
23
+ if (!data || !Array.isArray(data.tickets)) return { tickets: [] };
24
+ return data;
25
+ }
26
+
27
+ async function saveTickets(data) {
28
+ await saveEncryptedJson(TICKETS_FILE, data, FEEDBACK_AAD);
29
+ }
30
+
31
+ export function registerFeedbackRoutes(app, { requireAdminTurnstile, verifyLimiter, logAdminEvent, ADMIN_TOKEN, getRequestIp }) {
32
+
33
+ // POST /api/feedback/submit — public, no auth required
34
+ app.post('/api/feedback/submit', async (req, res) => {
35
+ try {
36
+ const type = req.body?.type === 'improvement' ? 'improvement' : 'issue';
37
+ const environment = type === 'issue'
38
+ ? (req.body?.environment === 'beta' ? 'beta' : 'production')
39
+ : null;
40
+
41
+ const title = sanitizeString(req.body?.title, MAX_TITLE_LENGTH);
42
+ const body = sanitizeString(req.body?.body, MAX_BODY_LENGTH);
43
+
44
+ if (!title) {
45
+ return res.status(400).json({ error: 'feedback:title_required', message: 'A title is required.' });
46
+ }
47
+ if (!body) {
48
+ return res.status(400).json({ error: 'feedback:body_required', message: 'A description is required.' });
49
+ }
50
+
51
+ const ticket = {
52
+ id: generateTicketId(),
53
+ type,
54
+ environment,
55
+ title,
56
+ body,
57
+ status: 'open',
58
+ submittedAt: new Date().toISOString(),
59
+ resolvedAt: null,
60
+ ip: getRequestIp(req),
61
+ userAgent: (req.headers['user-agent'] || '').slice(0, 200),
62
+ };
63
+
64
+ const data = await loadTickets();
65
+ data.tickets.push(ticket);
66
+ await saveTickets(data);
67
+
68
+ console.log(`[FEEDBACK] New ${type} ticket submitted: "${title.slice(0, 60)}" id=${ticket.id}`);
69
+
70
+ return res.json({ success: true, id: ticket.id });
71
+ } catch (err) {
72
+ console.error('feedback submit error', err);
73
+ return res.status(500).json({ error: 'feedback:server_error', message: 'Failed to submit ticket.' });
74
+ }
75
+ });
76
+
77
+ // GET /admin/feedback — list tickets, requires admin auth + turnstile
78
+ app.get('/admin/feedback', requireAdminTurnstile, verifyLimiter, async (req, res) => {
79
+ const token = req.query.token;
80
+ if (token !== ADMIN_TOKEN) {
81
+ logAdminEvent(req, 'feedback_list_denied', { reason: 'bad_token' });
82
+ return res.status(403).json({ error: 'Forbidden' });
83
+ }
84
+
85
+ try {
86
+ const data = await loadTickets();
87
+ const tickets = data.tickets.map(t => ({
88
+ id: t.id,
89
+ type: t.type,
90
+ environment: t.environment,
91
+ title: t.title,
92
+ body: t.body,
93
+ status: t.status,
94
+ submittedAt: t.submittedAt,
95
+ resolvedAt: t.resolvedAt,
96
+ }));
97
+ logAdminEvent(req, 'feedback_list_view', { count: tickets.length });
98
+ return res.json({ tickets });
99
+ } catch (err) {
100
+ console.error('feedback list error', err);
101
+ return res.status(500).json({ error: 'feedback:server_error' });
102
+ }
103
+ });
104
+
105
+ // POST /admin/feedback/:id/resolve — mark a ticket resolved
106
+ app.post('/admin/feedback/:id/resolve', requireAdminTurnstile, verifyLimiter, async (req, res) => {
107
+ const token = req.body?.token || req.query.token;
108
+ if (token !== ADMIN_TOKEN) {
109
+ logAdminEvent(req, 'feedback_resolve_denied', { reason: 'bad_token', ticketId: req.params.id });
110
+ return res.status(403).json({ error: 'Forbidden' });
111
+ }
112
+
113
+ try {
114
+ const data = await loadTickets();
115
+ const ticket = data.tickets.find(t => t.id === req.params.id);
116
+ if (!ticket) {
117
+ return res.status(404).json({ error: 'feedback:not_found' });
118
+ }
119
+
120
+ ticket.status = 'resolved';
121
+ ticket.resolvedAt = new Date().toISOString();
122
+ await saveTickets(data);
123
+
124
+ logAdminEvent(req, 'feedback_resolved', { ticketId: ticket.id, title: ticket.title.slice(0, 60) });
125
+ return res.json({ success: true, ticket: {
126
+ id: ticket.id, status: ticket.status, resolvedAt: ticket.resolvedAt
127
+ }});
128
+ } catch (err) {
129
+ console.error('feedback resolve error', err);
130
+ return res.status(500).json({ error: 'feedback:server_error' });
131
+ }
132
+ });
133
+
134
+ // POST /admin/feedback/:id/reopen — reopen a resolved ticket
135
+ app.post('/admin/feedback/:id/reopen', requireAdminTurnstile, verifyLimiter, async (req, res) => {
136
+ const token = req.body?.token || req.query.token;
137
+ if (token !== ADMIN_TOKEN) {
138
+ logAdminEvent(req, 'feedback_reopen_denied', { reason: 'bad_token', ticketId: req.params.id });
139
+ return res.status(403).json({ error: 'Forbidden' });
140
+ }
141
+
142
+ try {
143
+ const data = await loadTickets();
144
+ const ticket = data.tickets.find(t => t.id === req.params.id);
145
+ if (!ticket) {
146
+ return res.status(404).json({ error: 'feedback:not_found' });
147
+ }
148
+
149
+ ticket.status = 'open';
150
+ ticket.resolvedAt = null;
151
+ await saveTickets(data);
152
+
153
+ logAdminEvent(req, 'feedback_reopened', { ticketId: ticket.id });
154
+ return res.json({ success: true, ticket: {
155
+ id: ticket.id, status: ticket.status, resolvedAt: ticket.resolvedAt
156
+ }});
157
+ } catch (err) {
158
+ console.error('feedback reopen error', err);
159
+ return res.status(500).json({ error: 'feedback:server_error' });
160
+ }
161
+ });
162
+ }
server/index.js CHANGED
@@ -8,11 +8,13 @@ import { fileURLToPath } from 'url';
8
  import fetch from 'node-fetch';
9
  import rateLimit from 'express-rate-limit';
10
  import fs from 'fs';
11
-
12
  import { handleWsMessage } from './wsHandler.js';
13
  import { sessionStore, initStoreConfig } from './sessionStore.js';
14
  import { SUPABASE_URL, SUPABASE_ANON_KEY } from './config.js';
15
  import { safeSend } from './helpers.js';
 
 
16
 
17
  export { SUPABASE_URL, SUPABASE_ANON_KEY };
18
  export { LIGHTNING_BASE, PUBLIC_URL } from './config.js';
@@ -29,14 +31,131 @@ const GITHUB_REPO = 'incognitolm/InferencePort-Pages';
29
  const CDN_BASE = `https://cdn.jsdelivr.net/gh/${GITHUB_REPO}`;
30
  let latestSHA = null;
31
  const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'supersecret';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
  // Rate limiter for admin endpoints (5 attempts per IP per minute)
34
- const verifyLimiter = rateLimit({ windowMs: 60*1000, max: 5, standardHeaders: true, legacyHeaders: false });
 
 
 
 
 
 
 
 
 
35
 
36
  const DATA_DIR = "/data";
37
  const VERSION_FILE = path.join(DATA_DIR, 'version.json');
38
  const PUBLIC_URL = process.env.PUBLIC_URL || 'default';
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  function loadStoredSHA() {
42
  try {
@@ -98,6 +217,7 @@ function saveStoredSHA(sha) {
98
 
99
  app.use(express.json({ limit: '10mb' }));
100
 
 
101
  app.use('/api', (req, res, next) => {
102
  const exempt = ['/turnstile', '/health'];
103
  if (exempt.includes(req.path)) return next();
@@ -106,6 +226,45 @@ app.use('/api', (req, res, next) => {
106
  return res.status(403).json({ error: 'turnstile:required' });
107
  });
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  app.get('/health', (_req,res) => res.json({ok:true}));
110
 
111
  app.get('/api/share/:token', async (req,res) => {
@@ -123,17 +282,237 @@ app.get('/api/share/:token', async (req,res) => {
123
  } catch { res.status(500).json({error:'Server error'}); }
124
  });
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  app.post('/api/turnstile', async (req,res)=>{
127
  try{
128
  const token = req.body?.token;
129
- const secret = process.env.TURNSTILE_SECRET_KEY;
130
- if(!token||!secret) return res.status(400).json({error:'Missing token or server not configured'});
131
- const params = new URLSearchParams();
132
- params.append('secret',secret);
133
- params.append('response',token);
134
- if(req.ip) params.append('remoteip',req.ip);
135
- const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify',{method:'POST',body:params});
136
- const j = await r.json();
137
  if(j?.success){
138
  res.cookie('turnstile','1',{ maxAge:24*3600*1000, path:'/', sameSite:'lax'});
139
  return res.json({success:true});
@@ -163,7 +542,10 @@ if (!latestSHA) {
163
  console.log(`[${PUBLIC_URL}] Using stored SHA: ${latestSHA}`);
164
  }
165
 
 
166
  app.get('/admin.html', async (req, res) => {
 
 
167
  if (!latestSHA) return res.status(500).send('Server not ready');
168
 
169
  const url = `${CDN_BASE}@${latestSHA}/admin.html`;
@@ -181,28 +563,90 @@ app.get('/admin.html', async (req, res) => {
181
  }
182
  });
183
 
184
- app.get('/admin/verify',verifyLimiter,(req,res)=>{
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  const token = req.query.token;
186
- res.json({success: token===ADMIN_TOKEN});
 
 
 
 
 
187
  });
188
 
189
- app.get('/admin/refresh', verifyLimiter, async (req, res) => {
190
  const token = req.query.token;
191
- if (token !== ADMIN_TOKEN) return res.status(403).send('Forbidden');
 
 
 
192
 
193
  const sha = req.query.sha?.trim();
194
  if (sha) {
195
- if (!/^[0-9a-f]{7,40}$/.test(sha)) return res.status(400).send('Invalid SHA');
 
 
 
196
  latestSHA = sha;
197
  saveStoredSHA(latestSHA);
 
198
  console.log(`[${PUBLIC_URL}] Manual SHA set by admin: ${latestSHA}`);
199
  return res.send(`Version set to commit ${latestSHA}`);
200
  }
201
 
202
  await fetchLatestSHA();
 
203
  res.send(`Latest version refreshed: ${latestSHA}`);
204
  });
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  function getMimeType(filePath){
207
  const ext = path.extname(filePath).toLowerCase();
208
  switch(ext){
@@ -220,10 +664,31 @@ function getMimeType(filePath){
220
  }
221
  }
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  app.get('*', async (req,res)=>{
224
  if(req.path.startsWith('/api/')) return res.status(404).send('Not found');
225
 
 
226
  const filePath = req.path === '/' ? '/index.html' : req.path;
 
227
  const url = `${CDN_BASE}@${latestSHA}${filePath}`;
228
 
229
  try{
@@ -233,6 +698,7 @@ app.get('*', async (req,res)=>{
233
  const mimeType = getMimeType(filePath);
234
  res.setHeader('Content-Type', mimeType);
235
 
 
236
  if(mimeType.startsWith('text') || mimeType==='application/javascript' || mimeType==='application/json'){
237
  const text = await response.text();
238
  res.send(text);
@@ -246,6 +712,7 @@ app.get('*', async (req,res)=>{
246
  }
247
  });
248
 
 
249
  const httpServer = createServer(app);
250
  const wss = new WebSocketServer({server:httpServer,path:'/ws'});
251
 
@@ -270,4 +737,4 @@ wss.on('connection',(ws,req)=>{
270
  safeSend(ws,{type:'connected', tempId:wsClients.get(ws)?.tempId});
271
  });
272
 
273
- httpServer.listen(PORT,'0.0.0.0',()=>console.log(`Running on port ${PORT}`));
 
8
  import fetch from 'node-fetch';
9
  import rateLimit from 'express-rate-limit';
10
  import fs from 'fs';
11
+ import { registerFeedbackRoutes } from './handleFeedback.js';
12
  import { handleWsMessage } from './wsHandler.js';
13
  import { sessionStore, initStoreConfig } from './sessionStore.js';
14
  import { SUPABASE_URL, SUPABASE_ANON_KEY } from './config.js';
15
  import { safeSend } from './helpers.js';
16
+ import { verifySupabaseToken } from './auth.js';
17
+ import { mediaStore } from './mediaStore.js';
18
 
19
  export { SUPABASE_URL, SUPABASE_ANON_KEY };
20
  export { LIGHTNING_BASE, PUBLIC_URL } from './config.js';
 
31
  const CDN_BASE = `https://cdn.jsdelivr.net/gh/${GITHUB_REPO}`;
32
  let latestSHA = null;
33
  const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'supersecret';
34
+ const TURNSTILE_SITE_KEY = process.env.TURNSTILE_SITE_KEY || '0x4AAAAAAC1ZXKIhZ9Kdz8j9';
35
+ const MAX_TEXT_UPLOAD_BYTES = 100 * 1024;
36
+
37
+ const LOCAL_UI_DIR = [
38
+ process.env.UI_LOCAL_PATH,
39
+ path.resolve(__dirname, '..', '..', 'InferencePort-Pages'),
40
+ path.resolve(process.cwd(), '..', 'InferencePort-Pages'),
41
+ ].filter(Boolean).find((dir) => {
42
+ try {
43
+ return fs.existsSync(path.join(dir, 'index.html'));
44
+ } catch {
45
+ return false;
46
+ }
47
+ }) || null;
48
 
49
  // Rate limiter for admin endpoints (5 attempts per IP per minute)
50
+ const verifyLimiter = rateLimit({
51
+ windowMs: 60 * 1000,
52
+ max: 5,
53
+ standardHeaders: true,
54
+ legacyHeaders: false,
55
+ handler: (req, res) => {
56
+ logAdminEvent(req, 'rate_limited');
57
+ res.status(429).json({ error: 'rate_limited' });
58
+ },
59
+ });
60
 
61
  const DATA_DIR = "/data";
62
  const VERSION_FILE = path.join(DATA_DIR, 'version.json');
63
  const PUBLIC_URL = process.env.PUBLIC_URL || 'default';
64
 
65
+ function getRequestIp(req) {
66
+ return (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
67
+ || req.socket?.remoteAddress
68
+ || req.ip
69
+ || 'unknown';
70
+ }
71
+
72
+ function truncateForLog(value, max = 180) {
73
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim();
74
+ if (!text) return '';
75
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
76
+ }
77
+
78
+ function extensionFromName(name = '') {
79
+ const ext = path.extname(String(name || '')).toLowerCase();
80
+ return ext.startsWith('.') ? ext.slice(1) : ext;
81
+ }
82
+
83
+ function isTextLikeUpload(name = '', mimeType = '', kind = '') {
84
+ const normalizedKind = String(kind || '').toLowerCase();
85
+ const mime = String(mimeType || '').toLowerCase();
86
+ const ext = extensionFromName(name);
87
+ if (normalizedKind === 'text' || normalizedKind === 'rich_text') return true;
88
+ if (mime.startsWith('text/')) return true;
89
+ if (['application/json', 'application/javascript', 'application/xml'].includes(mime)) return true;
90
+ return ['txt', 'md', 'json', 'js', 'ts', 'css', 'py', 'html', 'htm', 'xml', 'csv', 'rtf'].includes(ext);
91
+ }
92
+
93
+ function getCookieMap(req) {
94
+ const cookies = (req.headers.cookie || '')
95
+ .split(';')
96
+ .map((value) => value.trim())
97
+ .filter(Boolean)
98
+ .map((entry) => {
99
+ const idx = entry.indexOf('=');
100
+ if (idx === -1) return null;
101
+ return [entry.slice(0, idx), entry.slice(idx + 1)];
102
+ })
103
+ .filter(Boolean);
104
+ return Object.fromEntries(cookies);
105
+ }
106
+
107
+ function hasAdminTurnstile(req) {
108
+ return getCookieMap(req).admin_turnstile === '1';
109
+ }
110
+
111
+ function requireAdminTurnstile(req, res, next) {
112
+ if (hasAdminTurnstile(req)) return next();
113
+ logAdminEvent(req, 'blocked_missing_turnstile');
114
+ return res.status(403).json({ error: 'turnstile:required' });
115
+ }
116
+
117
+ function logAdminEvent(req, action, detail = null) {
118
+ const parts = [
119
+ `[ADMIN ${new Date().toISOString()}]`,
120
+ `action=${action}`,
121
+ `ip=${getRequestIp(req)}`,
122
+ `method=${req.method}`,
123
+ `path=${truncateForLog(req.originalUrl || req.url, 220)}`,
124
+ ];
125
+ const userAgent = truncateForLog(req.headers['user-agent'] || 'unknown', 140);
126
+ if (userAgent) parts.push(`ua="${userAgent}"`);
127
+ if (typeof detail === 'string' && detail.trim()) {
128
+ parts.push(detail.trim());
129
+ } else if (detail && typeof detail === 'object') {
130
+ Object.entries(detail).forEach(([key, value]) => {
131
+ if (value === undefined || value === null || value === '') return;
132
+ parts.push(`${key}=${JSON.stringify(String(value))}`);
133
+ });
134
+ }
135
+ console.log(parts.join(' | '));
136
+ }
137
+
138
+ function respondTextUploadTooLarge(res) {
139
+ return res.status(413).json({
140
+ error: 'media:text_too_large',
141
+ message: 'Text files must be 100 KB or smaller.',
142
+ });
143
+ }
144
+
145
+ async function verifyTurnstileToken(token, remoteIp) {
146
+ const secret = process.env.TURNSTILE_SECRET_KEY;
147
+ if (!token || !secret) throw new Error('Missing token or server not configured');
148
+ const params = new URLSearchParams();
149
+ params.append('secret', secret);
150
+ params.append('response', token);
151
+ if (remoteIp) params.append('remoteip', remoteIp);
152
+ const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
153
+ method: 'POST',
154
+ body: params,
155
+ });
156
+ return response.json();
157
+ }
158
+
159
 
160
  function loadStoredSHA() {
161
  try {
 
217
 
218
  app.use(express.json({ limit: '10mb' }));
219
 
220
+ // --- API Turnstile Protection ---
221
  app.use('/api', (req, res, next) => {
222
  const exempt = ['/turnstile', '/health'];
223
  if (exempt.includes(req.path)) return next();
 
226
  return res.status(403).json({ error: 'turnstile:required' });
227
  });
228
 
229
+ registerFeedbackRoutes(app, {
230
+ requireAdminTurnstile,
231
+ verifyLimiter,
232
+ logAdminEvent,
233
+ ADMIN_TOKEN,
234
+ getRequestIp,
235
+ });
236
+
237
+ async function getRequestOwner(req) {
238
+ const authHeader = req.headers.authorization || '';
239
+ const accessToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
240
+ if (accessToken) {
241
+ const user = await verifySupabaseToken(accessToken);
242
+ if (user) return { owner: { type: 'user', id: user.id }, accessToken };
243
+ }
244
+
245
+ const tempId = String(req.headers['x-temp-id'] || req.query.tempId || '').trim();
246
+ if (tempId) {
247
+ sessionStore.initTemp(tempId);
248
+ return { owner: { type: 'guest', id: tempId }, accessToken: null };
249
+ }
250
+ return null;
251
+ }
252
+
253
+ async function requireRequestOwner(req, res) {
254
+ const resolved = await getRequestOwner(req);
255
+ if (!resolved?.owner) {
256
+ res.status(401).json({ error: 'auth:required' });
257
+ return null;
258
+ }
259
+ return resolved;
260
+ }
261
+
262
+ function requireSignedInMediaOwner(resolved, res, message = 'Sign in to upload files.') {
263
+ if (resolved?.owner?.type === 'user') return true;
264
+ res.status(403).json({ error: 'media:auth_required', message });
265
+ return false;
266
+ }
267
+
268
  app.get('/health', (_req,res) => res.json({ok:true}));
269
 
270
  app.get('/api/share/:token', async (req,res) => {
 
282
  } catch { res.status(500).json({error:'Server error'}); }
283
  });
284
 
285
+ app.get('/api/media', async (req, res) => {
286
+ const resolved = await requireRequestOwner(req, res);
287
+ if (!resolved) return;
288
+ try {
289
+ const result = await mediaStore.list(resolved.owner, {
290
+ view: req.query.view === 'trash' ? 'trash' : 'active',
291
+ parentId: req.query.parentId ? String(req.query.parentId) : null,
292
+ });
293
+ res.json(result);
294
+ } catch (err) {
295
+ console.error('media list error', err);
296
+ res.status(500).json({ error: 'media:list_failed' });
297
+ }
298
+ });
299
+
300
+ app.post('/api/media/upload', express.raw({ type: () => true, limit: '100mb' }), async (req, res) => {
301
+ const resolved = await requireRequestOwner(req, res);
302
+ if (!resolved) return;
303
+ if (!requireSignedInMediaOwner(resolved, res, 'Sign in to upload files.')) return;
304
+ try {
305
+ const name = decodeURIComponent(String(req.headers['x-file-name'] || 'upload.bin'));
306
+ const mimeType = String(req.headers['x-mime-type'] || 'application/octet-stream');
307
+ const parentId = req.headers['x-parent-id'] ? String(req.headers['x-parent-id']) : null;
308
+ const sessionId = req.headers['x-session-id'] ? String(req.headers['x-session-id']) : null;
309
+ const kindHeader = req.headers['x-file-kind'] ? String(req.headers['x-file-kind']) : null;
310
+ const buffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || []);
311
+ if (isTextLikeUpload(name, mimeType, kindHeader) && buffer.byteLength > MAX_TEXT_UPLOAD_BYTES) {
312
+ return respondTextUploadTooLarge(res);
313
+ }
314
+ const item = await mediaStore.storeBuffer(resolved.owner, {
315
+ name,
316
+ mimeType,
317
+ buffer,
318
+ parentId,
319
+ sessionId,
320
+ source: 'user_upload',
321
+ kind: kindHeader || null,
322
+ });
323
+ const usage = await mediaStore.getUsage(resolved.owner);
324
+ res.json({ item, usage });
325
+ } catch (err) {
326
+ console.error('media upload error', err);
327
+ res.status(err.status || 500).json({
328
+ error: err.code || 'media:upload_failed',
329
+ message: err.message || 'Upload failed',
330
+ usage: err.usage || null,
331
+ });
332
+ }
333
+ });
334
+
335
+ app.post('/api/media/folders', async (req, res) => {
336
+ const resolved = await requireRequestOwner(req, res);
337
+ if (!resolved) return;
338
+ if (!requireSignedInMediaOwner(resolved, res, 'Sign in to create folders.')) return;
339
+ try {
340
+ const item = await mediaStore.createFolder(resolved.owner, {
341
+ name: req.body?.name || 'New Folder',
342
+ parentId: req.body?.parentId || null,
343
+ });
344
+ const usage = await mediaStore.getUsage(resolved.owner);
345
+ res.json({ item, usage });
346
+ } catch (err) {
347
+ console.error('media create folder error', err);
348
+ res.status(500).json({ error: 'media:create_folder_failed' });
349
+ }
350
+ });
351
+
352
+ app.post('/api/media/documents', async (req, res) => {
353
+ const resolved = await requireRequestOwner(req, res);
354
+ if (!resolved) return;
355
+ if (!requireSignedInMediaOwner(resolved, res, 'Sign in to create documents.')) return;
356
+ try {
357
+ const richText = !!req.body?.richText;
358
+ const content = String(req.body?.content || '');
359
+ if (Buffer.byteLength(content, 'utf8') > MAX_TEXT_UPLOAD_BYTES) {
360
+ return respondTextUploadTooLarge(res);
361
+ }
362
+ const item = await mediaStore.createDocument(resolved.owner, {
363
+ name: req.body?.name,
364
+ parentId: req.body?.parentId || null,
365
+ richText,
366
+ content,
367
+ source: 'user_upload',
368
+ sessionId: req.body?.sessionId || null,
369
+ });
370
+ const usage = await mediaStore.getUsage(resolved.owner);
371
+ res.json({ item, usage });
372
+ } catch (err) {
373
+ console.error('media create document error', err);
374
+ res.status(err.status || 500).json({
375
+ error: err.code || 'media:create_document_failed',
376
+ message: err.message || 'Document creation failed',
377
+ usage: err.usage || null,
378
+ });
379
+ }
380
+ });
381
+
382
+ app.post('/api/media/move', async (req, res) => {
383
+ const resolved = await requireRequestOwner(req, res);
384
+ if (!resolved) return;
385
+ if (!requireSignedInMediaOwner(resolved, res, 'Sign in to move files.')) return;
386
+ try {
387
+ const items = await mediaStore.move(resolved.owner, req.body?.ids || [], req.body?.parentId || null);
388
+ const usage = await mediaStore.getUsage(resolved.owner);
389
+ res.json({ items, usage });
390
+ } catch (err) {
391
+ console.error('media move error', err);
392
+ res.status(500).json({ error: 'media:move_failed' });
393
+ }
394
+ });
395
+
396
+ app.post('/api/media/trash', async (req, res) => {
397
+ const resolved = await requireRequestOwner(req, res);
398
+ if (!resolved) return;
399
+ try {
400
+ const items = await mediaStore.moveToTrash(resolved.owner, req.body?.ids || []);
401
+ const usage = await mediaStore.getUsage(resolved.owner);
402
+ res.json({ items, usage });
403
+ } catch (err) {
404
+ console.error('media trash error', err);
405
+ res.status(500).json({ error: 'media:trash_failed' });
406
+ }
407
+ });
408
+
409
+ app.post('/api/media/restore', async (req, res) => {
410
+ const resolved = await requireRequestOwner(req, res);
411
+ if (!resolved) return;
412
+ if (!requireSignedInMediaOwner(resolved, res, 'Sign in to restore files.')) return;
413
+ try {
414
+ const items = await mediaStore.restore(resolved.owner, req.body?.ids || []);
415
+ const usage = await mediaStore.getUsage(resolved.owner);
416
+ res.json({ items, usage });
417
+ } catch (err) {
418
+ console.error('media restore error', err);
419
+ res.status(500).json({ error: 'media:restore_failed' });
420
+ }
421
+ });
422
+
423
+ app.post('/api/media/deleteForever', async (req, res) => {
424
+ const resolved = await requireRequestOwner(req, res);
425
+ if (!resolved) return;
426
+ try {
427
+ const ids = await mediaStore.deleteForever(resolved.owner, req.body?.ids || []);
428
+ const usage = await mediaStore.getUsage(resolved.owner);
429
+ res.json({ ids, usage });
430
+ } catch (err) {
431
+ console.error('media delete forever error', err);
432
+ res.status(500).json({ error: 'media:delete_failed' });
433
+ }
434
+ });
435
+
436
+ app.get('/api/media/:id/text', async (req, res) => {
437
+ const resolved = await requireRequestOwner(req, res);
438
+ if (!resolved) return;
439
+ try {
440
+ const loaded = await mediaStore.readText(resolved.owner, req.params.id);
441
+ if (!loaded) return res.status(404).json({ error: 'media:not_found' });
442
+ res.json({ item: loaded.entry, content: loaded.text });
443
+ } catch (err) {
444
+ console.error('media read text error', err);
445
+ res.status(500).json({ error: 'media:read_failed' });
446
+ }
447
+ });
448
+
449
+ app.put('/api/media/:id/text', async (req, res) => {
450
+ const resolved = await requireRequestOwner(req, res);
451
+ if (!resolved) return;
452
+ if (!requireSignedInMediaOwner(resolved, res, 'Sign in to edit files.')) return;
453
+ try {
454
+ const buffer = Buffer.from(String(req.body?.content || ''), 'utf8');
455
+ if (buffer.byteLength > MAX_TEXT_UPLOAD_BYTES) {
456
+ return respondTextUploadTooLarge(res);
457
+ }
458
+ const item = await mediaStore.updateContent(resolved.owner, req.params.id, {
459
+ buffer,
460
+ mimeType: req.body?.mimeType || null,
461
+ kind: req.body?.richText ? 'rich_text' : null,
462
+ name: req.body?.name || null,
463
+ });
464
+ if (!item) return res.status(404).json({ error: 'media:not_found' });
465
+ const usage = await mediaStore.getUsage(resolved.owner);
466
+ res.json({ item, usage });
467
+ } catch (err) {
468
+ console.error('media update text error', err);
469
+ res.status(err.status || 500).json({
470
+ error: err.code || 'media:update_failed',
471
+ message: err.message || 'Update failed',
472
+ usage: err.usage || null,
473
+ });
474
+ }
475
+ });
476
+
477
+ app.post('/api/media/:id/rename', async (req, res) => {
478
+ const resolved = await requireRequestOwner(req, res);
479
+ if (!resolved) return;
480
+ if (!requireSignedInMediaOwner(resolved, res, 'Sign in to rename files.')) return;
481
+ try {
482
+ const name = String(req.body?.name || '').trim();
483
+ if (!name) return res.status(400).json({ error: 'media:name_required', message: 'A file name is required.' });
484
+ const item = await mediaStore.rename(resolved.owner, req.params.id, name);
485
+ if (!item) return res.status(404).json({ error: 'media:not_found' });
486
+ const usage = await mediaStore.getUsage(resolved.owner);
487
+ res.json({ item, usage });
488
+ } catch (err) {
489
+ console.error('media rename error', err);
490
+ res.status(500).json({ error: 'media:rename_failed', message: err.message || 'Rename failed' });
491
+ }
492
+ });
493
+
494
+ app.get('/api/media/:id/content', async (req, res) => {
495
+ const resolved = await requireRequestOwner(req, res);
496
+ if (!resolved) return;
497
+ try {
498
+ const loaded = await mediaStore.readBuffer(resolved.owner, req.params.id);
499
+ if (!loaded) return res.status(404).json({ error: 'media:not_found' });
500
+ res.setHeader('Content-Type', loaded.entry.mimeType || 'application/octet-stream');
501
+ res.setHeader('Cache-Control', 'private, max-age=60');
502
+ if (req.query.download === '1') {
503
+ res.setHeader('Content-Disposition', `attachment; filename="${loaded.entry.name}"`);
504
+ }
505
+ res.send(loaded.buffer);
506
+ } catch (err) {
507
+ console.error('media read binary error', err);
508
+ res.status(500).json({ error: 'media:read_failed' });
509
+ }
510
+ });
511
+
512
  app.post('/api/turnstile', async (req,res)=>{
513
  try{
514
  const token = req.body?.token;
515
+ const j = await verifyTurnstileToken(token, getRequestIp(req));
 
 
 
 
 
 
 
516
  if(j?.success){
517
  res.cookie('turnstile','1',{ maxAge:24*3600*1000, path:'/', sameSite:'lax'});
518
  return res.json({success:true});
 
542
  console.log(`[${PUBLIC_URL}] Using stored SHA: ${latestSHA}`);
543
  }
544
 
545
+ // --- Admin endpoints ---
546
  app.get('/admin.html', async (req, res) => {
547
+ logAdminEvent(req, 'page_view');
548
+ if (serveLocalUiFile('/admin.html', res)) return;
549
  if (!latestSHA) return res.status(500).send('Server not ready');
550
 
551
  const url = `${CDN_BASE}@${latestSHA}/admin.html`;
 
563
  }
564
  });
565
 
566
+ app.get('/admin/config', (req, res) => {
567
+ logAdminEvent(req, 'config_view', { verified: hasAdminTurnstile(req) });
568
+ res.json({
569
+ siteKey: TURNSTILE_SITE_KEY || null,
570
+ verified: hasAdminTurnstile(req),
571
+ });
572
+ });
573
+
574
+ app.post('/admin/turnstile', async (req, res) => {
575
+ try {
576
+ const token = req.body?.token;
577
+ const result = await verifyTurnstileToken(token, getRequestIp(req));
578
+ if (!result?.success) {
579
+ logAdminEvent(req, 'turnstile_failed', {
580
+ errorCodes: Array.isArray(result?.['error-codes']) ? result['error-codes'].join(',') : '',
581
+ });
582
+ return res.status(403).json({ error: 'Verification failed' });
583
+ }
584
+ res.cookie('admin_turnstile', '1', { maxAge: 2 * 3600 * 1000, path: '/', sameSite: 'lax' });
585
+ logAdminEvent(req, 'turnstile_verified');
586
+ return res.json({ success: true });
587
+ } catch (err) {
588
+ console.error('admin turnstile verify', err);
589
+ logAdminEvent(req, 'turnstile_error', { message: err.message || 'unknown' });
590
+ return res.status(500).json({ error: err.message || 'Server error' });
591
+ }
592
+ });
593
+
594
+ app.get('/admin/verify', requireAdminTurnstile, verifyLimiter, (req,res)=>{
595
  const token = req.query.token;
596
+ const success = token===ADMIN_TOKEN;
597
+ logAdminEvent(req, success ? 'login_success' : 'login_failed', {
598
+ turnstile: hasAdminTurnstile(req),
599
+ tokenProvided: !!token,
600
+ });
601
+ res.json({success});
602
  });
603
 
604
+ app.get('/admin/refresh', requireAdminTurnstile, verifyLimiter, async (req, res) => {
605
  const token = req.query.token;
606
+ if (token !== ADMIN_TOKEN) {
607
+ logAdminEvent(req, 'refresh_denied', { reason: 'bad_token' });
608
+ return res.status(403).send('Forbidden');
609
+ }
610
 
611
  const sha = req.query.sha?.trim();
612
  if (sha) {
613
+ if (!/^[0-9a-f]{7,40}$/.test(sha)) {
614
+ logAdminEvent(req, 'set_sha_invalid', { requestedSha: sha });
615
+ return res.status(400).send('Invalid SHA');
616
+ }
617
  latestSHA = sha;
618
  saveStoredSHA(latestSHA);
619
+ logAdminEvent(req, 'set_sha', { requestedSha: latestSHA });
620
  console.log(`[${PUBLIC_URL}] Manual SHA set by admin: ${latestSHA}`);
621
  return res.send(`Version set to commit ${latestSHA}`);
622
  }
623
 
624
  await fetchLatestSHA();
625
+ logAdminEvent(req, 'refresh_latest', { resolvedSha: latestSHA });
626
  res.send(`Latest version refreshed: ${latestSHA}`);
627
  });
628
 
629
+ app.get('/admin/status', requireAdminTurnstile, verifyLimiter, async (req, res) => {
630
+ const token = req.query.token;
631
+ if (token !== ADMIN_TOKEN) {
632
+ logAdminEvent(req, 'status_denied', { reason: 'bad_token' });
633
+ return res.status(403).json({ error: 'Forbidden' });
634
+ }
635
+ logAdminEvent(req, 'status_view', {
636
+ currentSha: latestSHA,
637
+ servingMode: LOCAL_UI_DIR ? 'local-ui' : 'cdn',
638
+ });
639
+ res.json({
640
+ publicUrl: PUBLIC_URL,
641
+ currentSha: latestSHA,
642
+ servingMode: LOCAL_UI_DIR ? 'local-ui' : 'cdn',
643
+ localUiDir: LOCAL_UI_DIR,
644
+ repo: GITHUB_REPO,
645
+ uiSource: LOCAL_UI_DIR ? 'Local InferencePort-Pages checkout is active' : 'Serving from CDN SHA',
646
+ });
647
+ });
648
+
649
+ // --- MIME type helper ---
650
  function getMimeType(filePath){
651
  const ext = path.extname(filePath).toLowerCase();
652
  switch(ext){
 
664
  }
665
  }
666
 
667
+ function resolveLocalUiFile(filePath) {
668
+ if (!LOCAL_UI_DIR) return null;
669
+ const resolvedRoot = path.resolve(LOCAL_UI_DIR);
670
+ const relativePath = String(filePath || '/index.html').replace(/^[/\\]+/, '');
671
+ const resolvedFile = path.resolve(resolvedRoot, relativePath);
672
+ if (resolvedFile !== resolvedRoot && !resolvedFile.startsWith(`${resolvedRoot}${path.sep}`)) return null;
673
+ if (!fs.existsSync(resolvedFile) || fs.statSync(resolvedFile).isDirectory()) return null;
674
+ return resolvedFile;
675
+ }
676
+
677
+ function serveLocalUiFile(filePath, res) {
678
+ const resolvedFile = resolveLocalUiFile(filePath);
679
+ if (!resolvedFile) return false;
680
+ res.setHeader('Content-Type', getMimeType(resolvedFile));
681
+ res.send(fs.readFileSync(resolvedFile));
682
+ return true;
683
+ }
684
+
685
+ // Hybrid serving: everything via latest SHA (auto-refresh / manual refresh)
686
  app.get('*', async (req,res)=>{
687
  if(req.path.startsWith('/api/')) return res.status(404).send('Not found');
688
 
689
+ // All client files are fetched from latest SHA
690
  const filePath = req.path === '/' ? '/index.html' : req.path;
691
+ if (serveLocalUiFile(filePath, res)) return;
692
  const url = `${CDN_BASE}@${latestSHA}${filePath}`;
693
 
694
  try{
 
698
  const mimeType = getMimeType(filePath);
699
  res.setHeader('Content-Type', mimeType);
700
 
701
+ // Stream text files; redirect others (images/fonts) optional
702
  if(mimeType.startsWith('text') || mimeType==='application/javascript' || mimeType==='application/json'){
703
  const text = await response.text();
704
  res.send(text);
 
712
  }
713
  });
714
 
715
+ // --- WebSocket ---
716
  const httpServer = createServer(app);
717
  const wss = new WebSocketServer({server:httpServer,path:'/ws'});
718
 
 
737
  safeSend(ws,{type:'connected', tempId:wsClients.get(ws)?.tempId});
738
  });
739
 
740
+ httpServer.listen(PORT,'0.0.0.0',()=>console.log(`Running on port ${PORT}`));
server/mediaStore.js ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from 'crypto';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import { loadEncryptedJson, saveEncryptedJson, readEncryptedFile, writeEncryptedFile } from './cryptoUtils.js';
5
+
6
+ const DATA_ROOT = '/data/media';
7
+ const INDEX_FILE = path.join(DATA_ROOT, 'index.json');
8
+ const BLOBS_DIR = path.join(DATA_ROOT, 'blobs');
9
+ const TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
10
+ const GUEST_RETENTION_MS = 24 * 60 * 60 * 1000;
11
+ const MEDIA_QUOTA_BYTES = 5 * 1024 * 1024 * 1024;
12
+
13
+ const state = {
14
+ loaded: false,
15
+ index: {
16
+ entries: {},
17
+ },
18
+ };
19
+
20
+ function nowIso() {
21
+ return new Date().toISOString();
22
+ }
23
+
24
+ function ownerKey(owner) {
25
+ return `${owner.type}:${owner.id}`;
26
+ }
27
+
28
+ function ensureOwner(owner) {
29
+ if (!owner?.type || !owner?.id) throw new Error('Invalid media owner');
30
+ return owner;
31
+ }
32
+
33
+ function normalizeName(name, fallback = 'Untitled') {
34
+ const clean = String(name || '').trim().replace(/[\\/:*?"<>|]+/g, '_');
35
+ return clean || fallback;
36
+ }
37
+
38
+ function extensionFromName(name = '') {
39
+ const ext = path.extname(String(name || '')).toLowerCase();
40
+ return ext.startsWith('.') ? ext.slice(1) : ext;
41
+ }
42
+
43
+ function inferKind(name, mimeType = '') {
44
+ const mime = String(mimeType || '').toLowerCase();
45
+ const ext = extensionFromName(name);
46
+ if (mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
47
+ if (mime.startsWith('video/') || ['mp4', 'webm', 'mov'].includes(ext)) return 'video';
48
+ if (mime.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'm4a'].includes(ext)) return 'audio';
49
+ if (mime === 'text/html' || ['html', 'htm'].includes(ext)) return 'rich_text';
50
+ if (mime.startsWith('text/') || ['txt', 'md', 'json', 'js', 'ts', 'css', 'py', 'html', 'xml', 'csv', 'rtf'].includes(ext)) return 'text';
51
+ return 'file';
52
+ }
53
+
54
+ function guessMimeType(name, fallbackKind = 'file') {
55
+ const ext = extensionFromName(name);
56
+ switch (ext) {
57
+ case 'txt': return 'text/plain';
58
+ case 'md': return 'text/markdown';
59
+ case 'json': return 'application/json';
60
+ case 'html':
61
+ case 'htm': return 'text/html';
62
+ case 'css': return 'text/css';
63
+ case 'js': return 'application/javascript';
64
+ case 'ts': return 'text/plain';
65
+ case 'svg': return 'image/svg+xml';
66
+ case 'png': return 'image/png';
67
+ case 'jpg':
68
+ case 'jpeg': return 'image/jpeg';
69
+ case 'gif': return 'image/gif';
70
+ case 'webp': return 'image/webp';
71
+ case 'mp4': return 'video/mp4';
72
+ case 'webm': return 'video/webm';
73
+ case 'mp3': return 'audio/mpeg';
74
+ case 'wav': return 'audio/wav';
75
+ case 'ogg': return 'audio/ogg';
76
+ case 'csv': return 'text/csv';
77
+ default:
78
+ if (fallbackKind === 'rich_text') return 'text/html';
79
+ if (fallbackKind === 'text') return 'text/plain';
80
+ return 'application/octet-stream';
81
+ }
82
+ }
83
+
84
+ async function ensureLoaded() {
85
+ if (state.loaded) return;
86
+ const stored = await loadEncryptedJson(INDEX_FILE);
87
+ state.index = {
88
+ entries: stored?.entries || {},
89
+ };
90
+ state.loaded = true;
91
+ await purgeExpiredInternal();
92
+ }
93
+
94
+ async function saveIndex() {
95
+ await saveEncryptedJson(INDEX_FILE, state.index);
96
+ }
97
+
98
+ function getEntry(id) {
99
+ return state.index.entries[id] || null;
100
+ }
101
+
102
+ function canAccess(entry, owner) {
103
+ return !!entry && entry.ownerType === owner.type && entry.ownerId === owner.id;
104
+ }
105
+
106
+ function sanitizeEntry(entry) {
107
+ return {
108
+ id: entry.id,
109
+ type: entry.type,
110
+ name: entry.name,
111
+ parentId: entry.parentId || null,
112
+ ownerType: entry.ownerType,
113
+ ownerId: entry.ownerId,
114
+ mimeType: entry.mimeType || null,
115
+ kind: entry.kind || null,
116
+ size: entry.size || 0,
117
+ source: entry.source || null,
118
+ createdAt: entry.createdAt,
119
+ updatedAt: entry.updatedAt,
120
+ trashedAt: entry.trashedAt || null,
121
+ purgeAt: entry.purgeAt || null,
122
+ sessionIds: entry.sessionIds || [],
123
+ deletedByAssistant: !!entry.deletedByAssistant,
124
+ };
125
+ }
126
+
127
+ function blobPathFor(id) {
128
+ return path.join(BLOBS_DIR, `${id}.bin`);
129
+ }
130
+
131
+ function buildAad(entry) {
132
+ return `media:${entry.id}:${entry.ownerType}:${entry.ownerId}`;
133
+ }
134
+
135
+ function isOwnedFile(entry, owner) {
136
+ return entry?.type === 'file' && entry.ownerType === owner.type && entry.ownerId === owner.id;
137
+ }
138
+
139
+ function getChildren(owner, parentId, includeTrash = true) {
140
+ return Object.values(state.index.entries).filter((entry) =>
141
+ entry.ownerType === owner.type &&
142
+ entry.ownerId === owner.id &&
143
+ (entry.parentId || null) === (parentId || null) &&
144
+ (includeTrash || !entry.trashedAt)
145
+ );
146
+ }
147
+
148
+ function collectDescendantIds(owner, rootId, acc = new Set()) {
149
+ acc.add(rootId);
150
+ const children = getChildren(owner, rootId, true);
151
+ for (const child of children) {
152
+ collectDescendantIds(owner, child.id, acc);
153
+ }
154
+ return [...acc];
155
+ }
156
+
157
+ function resolveParentFolder(owner, parentId) {
158
+ if (!parentId) return null;
159
+ const parent = getEntry(parentId);
160
+ if (!parent || !canAccess(parent, owner) || parent.type !== 'folder') return null;
161
+ return parent.id;
162
+ }
163
+
164
+ function wouldCreateCycle(owner, entryId, candidateParentId) {
165
+ let currentId = candidateParentId || null;
166
+ while (currentId) {
167
+ if (currentId === entryId) return true;
168
+ const current = getEntry(currentId);
169
+ if (!current || !canAccess(current, owner)) return false;
170
+ currentId = current.parentId || null;
171
+ }
172
+ return false;
173
+ }
174
+
175
+ function createQuotaError(owner, usage) {
176
+ const err = new Error('Cloud storage limit reached. Delete files or empty trash to free space.');
177
+ err.code = 'media:quota_exceeded';
178
+ err.status = 413;
179
+ err.usage = usage || null;
180
+ err.owner = owner;
181
+ return err;
182
+ }
183
+
184
+ function computeUsage(owner) {
185
+ const files = Object.values(state.index.entries).filter((entry) => isOwnedFile(entry, owner));
186
+ const totalBytes = files.reduce((sum, entry) => sum + (entry.size || 0), 0);
187
+ const trashBytes = files.reduce((sum, entry) => sum + (entry.trashedAt ? (entry.size || 0) : 0), 0);
188
+ const activeBytes = totalBytes - trashBytes;
189
+ const quotaBytes = MEDIA_QUOTA_BYTES;
190
+ return {
191
+ quotaBytes,
192
+ totalBytes,
193
+ activeBytes,
194
+ trashBytes,
195
+ remainingBytes: Math.max(0, quotaBytes - totalBytes),
196
+ percentUsed: quotaBytes > 0 ? Math.min(100, (totalBytes / quotaBytes) * 100) : 0,
197
+ fileCount: files.length,
198
+ trashFileCount: files.filter((entry) => !!entry.trashedAt).length,
199
+ };
200
+ }
201
+
202
+ function assertQuotaAvailable(owner, additionalBytes = 0) {
203
+ const usage = computeUsage(owner);
204
+ if ((usage.totalBytes + Math.max(0, additionalBytes)) > usage.quotaBytes) {
205
+ throw createQuotaError(owner, usage);
206
+ }
207
+ return usage;
208
+ }
209
+
210
+ async function purgeEntry(id) {
211
+ const entry = getEntry(id);
212
+ if (!entry) return;
213
+ if (entry.type === 'file') {
214
+ await fs.rm(blobPathFor(id), { force: true }).catch(() => {});
215
+ }
216
+ delete state.index.entries[id];
217
+ }
218
+
219
+ async function purgeExpiredInternal() {
220
+ const now = Date.now();
221
+ let changed = false;
222
+ for (const entry of Object.values(state.index.entries)) {
223
+ const shouldPurge =
224
+ (entry.purgeAt && new Date(entry.purgeAt).getTime() <= now) ||
225
+ (entry.expiresAt && new Date(entry.expiresAt).getTime() <= now);
226
+ if (!shouldPurge) continue;
227
+ for (const id of collectDescendantIds({ type: entry.ownerType, id: entry.ownerId }, entry.id)) {
228
+ await purgeEntry(id);
229
+ }
230
+ changed = true;
231
+ }
232
+ if (changed) await saveIndex();
233
+ }
234
+
235
+ setInterval(() => {
236
+ purgeExpiredInternal().catch((err) => console.error('mediaStore cleanup failed:', err));
237
+ }, 6 * 60 * 60 * 1000);
238
+
239
+ export const mediaStore = {
240
+ async list(owner, { view = 'active', parentId = null } = {}) {
241
+ ensureOwner(owner);
242
+ await ensureLoaded();
243
+
244
+ const includeTrash = view === 'trash';
245
+ const items = getChildren(owner, parentId, true)
246
+ .filter((entry) => includeTrash ? !!entry.trashedAt : !entry.trashedAt)
247
+ .sort((a, b) => {
248
+ if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
249
+ return new Date(b.updatedAt || b.createdAt).getTime() - new Date(a.updatedAt || a.createdAt).getTime();
250
+ })
251
+ .map(sanitizeEntry);
252
+
253
+ const breadcrumbs = [];
254
+ let currentId = parentId;
255
+ while (currentId) {
256
+ const entry = getEntry(currentId);
257
+ if (!entry || !canAccess(entry, owner)) break;
258
+ breadcrumbs.unshift({ id: entry.id, name: entry.name });
259
+ currentId = entry.parentId || null;
260
+ }
261
+
262
+ return { items, breadcrumbs, usage: computeUsage(owner) };
263
+ },
264
+
265
+ async get(owner, id) {
266
+ ensureOwner(owner);
267
+ await ensureLoaded();
268
+ const entry = getEntry(id);
269
+ if (!canAccess(entry, owner)) return null;
270
+ return sanitizeEntry(entry);
271
+ },
272
+
273
+ async storeBuffer(owner, {
274
+ name,
275
+ mimeType,
276
+ buffer,
277
+ parentId = null,
278
+ sessionId = null,
279
+ source = 'upload',
280
+ kind = null,
281
+ deletedByAssistant = false,
282
+ }) {
283
+ ensureOwner(owner);
284
+ await ensureLoaded();
285
+ assertQuotaAvailable(owner, Buffer.byteLength(buffer));
286
+
287
+ const entryId = crypto.randomUUID();
288
+ const resolvedParentId = resolveParentFolder(owner, parentId);
289
+ const fileName = normalizeName(name, 'Untitled');
290
+ const inferredKind = kind || inferKind(fileName, mimeType);
291
+ const entry = {
292
+ id: entryId,
293
+ type: 'file',
294
+ name: fileName,
295
+ ownerType: owner.type,
296
+ ownerId: owner.id,
297
+ mimeType: mimeType || guessMimeType(fileName, inferredKind),
298
+ kind: inferredKind,
299
+ size: buffer.byteLength,
300
+ parentId: resolvedParentId,
301
+ source,
302
+ createdAt: nowIso(),
303
+ updatedAt: nowIso(),
304
+ trashedAt: null,
305
+ purgeAt: null,
306
+ sessionIds: sessionId ? [sessionId] : [],
307
+ expiresAt: owner.type === 'guest' ? new Date(Date.now() + GUEST_RETENTION_MS).toISOString() : null,
308
+ deletedByAssistant,
309
+ };
310
+
311
+ await writeEncryptedFile(blobPathFor(entry.id), Buffer.from(buffer), buildAad(entry));
312
+ state.index.entries[entry.id] = entry;
313
+ await saveIndex();
314
+ return sanitizeEntry(entry);
315
+ },
316
+
317
+ async createFolder(owner, { name, parentId = null }) {
318
+ ensureOwner(owner);
319
+ await ensureLoaded();
320
+ const resolvedParentId = resolveParentFolder(owner, parentId);
321
+
322
+ const folder = {
323
+ id: crypto.randomUUID(),
324
+ type: 'folder',
325
+ name: normalizeName(name, 'New Folder'),
326
+ ownerType: owner.type,
327
+ ownerId: owner.id,
328
+ parentId: resolvedParentId,
329
+ createdAt: nowIso(),
330
+ updatedAt: nowIso(),
331
+ trashedAt: null,
332
+ purgeAt: null,
333
+ sessionIds: [],
334
+ expiresAt: owner.type === 'guest' ? new Date(Date.now() + GUEST_RETENTION_MS).toISOString() : null,
335
+ };
336
+
337
+ state.index.entries[folder.id] = folder;
338
+ await saveIndex();
339
+ return sanitizeEntry(folder);
340
+ },
341
+
342
+ async createDocument(owner, {
343
+ name,
344
+ parentId = null,
345
+ richText = false,
346
+ content = '',
347
+ source = 'upload',
348
+ sessionId = null,
349
+ }) {
350
+ const normalizedName = normalizeName(
351
+ name,
352
+ richText ? 'Untitled Document.html' : 'Untitled Document.txt'
353
+ );
354
+ return this.storeBuffer(owner, {
355
+ name: normalizedName,
356
+ mimeType: richText ? 'text/html' : 'text/plain',
357
+ buffer: Buffer.from(content || (richText ? '<p></p>' : ''), 'utf8'),
358
+ parentId,
359
+ sessionId,
360
+ source,
361
+ kind: richText ? 'rich_text' : 'text',
362
+ });
363
+ },
364
+
365
+ async readBuffer(owner, id) {
366
+ ensureOwner(owner);
367
+ await ensureLoaded();
368
+ const entry = getEntry(id);
369
+ if (!entry || entry.type !== 'file' || !canAccess(entry, owner)) return null;
370
+ return {
371
+ entry: sanitizeEntry(entry),
372
+ buffer: await readEncryptedFile(blobPathFor(id), buildAad(entry)),
373
+ };
374
+ },
375
+
376
+ async readText(owner, id) {
377
+ const loaded = await this.readBuffer(owner, id);
378
+ if (!loaded) return null;
379
+ return {
380
+ entry: loaded.entry,
381
+ text: loaded.buffer.toString('utf8'),
382
+ };
383
+ },
384
+
385
+ async updateContent(owner, id, { buffer, name = null, mimeType = null, kind = null }) {
386
+ ensureOwner(owner);
387
+ await ensureLoaded();
388
+ const entry = getEntry(id);
389
+ if (!entry || entry.type !== 'file' || !canAccess(entry, owner)) return null;
390
+ const nextSize = Buffer.byteLength(buffer);
391
+ const delta = nextSize - (entry.size || 0);
392
+ if (delta > 0) assertQuotaAvailable(owner, delta);
393
+
394
+ if (name) entry.name = normalizeName(name, entry.name);
395
+ if (mimeType) entry.mimeType = mimeType;
396
+ if (kind) entry.kind = kind;
397
+ entry.size = nextSize;
398
+ entry.updatedAt = nowIso();
399
+
400
+ await writeEncryptedFile(blobPathFor(entry.id), Buffer.from(buffer), buildAad(entry));
401
+ await saveIndex();
402
+ return sanitizeEntry(entry);
403
+ },
404
+
405
+ async rename(owner, id, name) {
406
+ ensureOwner(owner);
407
+ await ensureLoaded();
408
+ const entry = getEntry(id);
409
+ if (!entry || !canAccess(entry, owner)) return null;
410
+ entry.name = normalizeName(name, entry.name || 'Untitled');
411
+ entry.updatedAt = nowIso();
412
+ await saveIndex();
413
+ return sanitizeEntry(entry);
414
+ },
415
+
416
+ async move(owner, ids, parentId = null) {
417
+ ensureOwner(owner);
418
+ await ensureLoaded();
419
+ const destinationId = parentId ? resolveParentFolder(owner, parentId) : null;
420
+ if (parentId && !destinationId) throw new Error('Invalid destination folder');
421
+ const updated = [];
422
+ for (const id of ids || []) {
423
+ const entry = getEntry(id);
424
+ if (!entry || !canAccess(entry, owner)) continue;
425
+ if (destinationId === entry.id) continue;
426
+ if (wouldCreateCycle(owner, entry.id, destinationId)) continue;
427
+ entry.parentId = destinationId;
428
+ entry.updatedAt = nowIso();
429
+ updated.push(sanitizeEntry(entry));
430
+ }
431
+ if (updated.length) await saveIndex();
432
+ return updated;
433
+ },
434
+
435
+ async moveToTrash(owner, ids) {
436
+ ensureOwner(owner);
437
+ await ensureLoaded();
438
+ const trashed = [];
439
+ const now = nowIso();
440
+ const purgeAt = new Date(Date.now() + TRASH_RETENTION_MS).toISOString();
441
+
442
+ for (const id of ids || []) {
443
+ const entry = getEntry(id);
444
+ if (!entry || !canAccess(entry, owner)) continue;
445
+ for (const targetId of collectDescendantIds(owner, id)) {
446
+ const target = getEntry(targetId);
447
+ if (!target) continue;
448
+ target.trashedAt = now;
449
+ target.purgeAt = purgeAt;
450
+ target.updatedAt = now;
451
+ trashed.push(sanitizeEntry(target));
452
+ }
453
+ }
454
+ if (trashed.length) await saveIndex();
455
+ return trashed;
456
+ },
457
+
458
+ async restore(owner, ids) {
459
+ ensureOwner(owner);
460
+ await ensureLoaded();
461
+ const restored = [];
462
+ for (const id of ids || []) {
463
+ const entry = getEntry(id);
464
+ if (!entry || !canAccess(entry, owner)) continue;
465
+ for (const targetId of collectDescendantIds(owner, id)) {
466
+ const target = getEntry(targetId);
467
+ if (!target) continue;
468
+ target.trashedAt = null;
469
+ target.purgeAt = null;
470
+ target.updatedAt = nowIso();
471
+ restored.push(sanitizeEntry(target));
472
+ }
473
+ }
474
+ if (restored.length) await saveIndex();
475
+ return restored;
476
+ },
477
+
478
+ async deleteForever(owner, ids) {
479
+ ensureOwner(owner);
480
+ await ensureLoaded();
481
+ const removedIds = new Set();
482
+ for (const id of ids || []) {
483
+ const entry = getEntry(id);
484
+ if (!entry || !canAccess(entry, owner)) continue;
485
+ for (const targetId of collectDescendantIds(owner, id)) {
486
+ removedIds.add(targetId);
487
+ }
488
+ }
489
+ for (const targetId of removedIds) {
490
+ await purgeEntry(targetId);
491
+ }
492
+ if (removedIds.size) await saveIndex();
493
+ return [...removedIds];
494
+ },
495
+
496
+ async attachToSession(owner, ids, sessionId) {
497
+ ensureOwner(owner);
498
+ if (!sessionId) return [];
499
+ await ensureLoaded();
500
+ const updated = [];
501
+ for (const id of ids || []) {
502
+ const entry = getEntry(id);
503
+ if (!entry || !canAccess(entry, owner)) continue;
504
+ entry.sessionIds = [...new Set([...(entry.sessionIds || []), sessionId])];
505
+ entry.updatedAt = nowIso();
506
+ updated.push(sanitizeEntry(entry));
507
+ }
508
+ if (updated.length) await saveIndex();
509
+ return updated;
510
+ },
511
+
512
+ async getUsage(owner) {
513
+ ensureOwner(owner);
514
+ await ensureLoaded();
515
+ return computeUsage(owner);
516
+ },
517
+ };
server/memoryStore.js ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from 'crypto';
2
+ import path from 'path';
3
+ import { loadEncryptedJson, saveEncryptedJson } from './cryptoUtils.js';
4
+
5
+ const DATA_ROOT = '/data/memories';
6
+ const INDEX_FILE = path.join(DATA_ROOT, 'index.json');
7
+ const MAX_MEMORY_LENGTH = 220;
8
+
9
+ const state = {
10
+ loaded: false,
11
+ index: {
12
+ memories: {},
13
+ },
14
+ };
15
+
16
+ function ensureOwner(owner) {
17
+ if (!owner?.type || !owner?.id) throw new Error('Invalid memory owner');
18
+ return owner;
19
+ }
20
+
21
+ function nowIso() {
22
+ return new Date().toISOString();
23
+ }
24
+
25
+ function sanitizeText(text) {
26
+ return String(text || '').replace(/\s+/g, ' ').trim().slice(0, MAX_MEMORY_LENGTH);
27
+ }
28
+
29
+ async function ensureLoaded() {
30
+ if (state.loaded) return;
31
+ const stored = await loadEncryptedJson(INDEX_FILE);
32
+ state.index = {
33
+ memories: stored?.memories || {},
34
+ };
35
+ state.loaded = true;
36
+ }
37
+
38
+ async function saveIndex() {
39
+ await saveEncryptedJson(INDEX_FILE, state.index);
40
+ }
41
+
42
+ function matchesOwner(memory, owner) {
43
+ return memory.ownerType === owner.type && memory.ownerId === owner.id;
44
+ }
45
+
46
+ function sanitize(memory) {
47
+ return {
48
+ id: memory.id,
49
+ content: memory.content,
50
+ source: memory.source || 'assistant',
51
+ sessionId: memory.sessionId || null,
52
+ createdAt: memory.createdAt,
53
+ updatedAt: memory.updatedAt,
54
+ };
55
+ }
56
+
57
+ export const memoryStore = {
58
+ async list(owner) {
59
+ ensureOwner(owner);
60
+ await ensureLoaded();
61
+ return Object.values(state.index.memories)
62
+ .filter((memory) => matchesOwner(memory, owner))
63
+ .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
64
+ .map(sanitize);
65
+ },
66
+
67
+ async create(owner, { content, sessionId = null, source = 'assistant' }) {
68
+ ensureOwner(owner);
69
+ await ensureLoaded();
70
+ const normalized = sanitizeText(content);
71
+ if (!normalized) return null;
72
+
73
+ const memory = {
74
+ id: crypto.randomUUID(),
75
+ ownerType: owner.type,
76
+ ownerId: owner.id,
77
+ content: normalized,
78
+ sessionId,
79
+ source,
80
+ createdAt: nowIso(),
81
+ updatedAt: nowIso(),
82
+ };
83
+ state.index.memories[memory.id] = memory;
84
+ await saveIndex();
85
+ return sanitize(memory);
86
+ },
87
+
88
+ async update(owner, id, content) {
89
+ ensureOwner(owner);
90
+ await ensureLoaded();
91
+ const memory = state.index.memories[id];
92
+ if (!memory || !matchesOwner(memory, owner)) return null;
93
+ const normalized = sanitizeText(content);
94
+ if (!normalized) return null;
95
+ memory.content = normalized;
96
+ memory.updatedAt = nowIso();
97
+ await saveIndex();
98
+ return sanitize(memory);
99
+ },
100
+
101
+ async delete(owner, id) {
102
+ ensureOwner(owner);
103
+ await ensureLoaded();
104
+ const memory = state.index.memories[id];
105
+ if (!memory || !matchesOwner(memory, owner)) return false;
106
+ delete state.index.memories[id];
107
+ await saveIndex();
108
+ return true;
109
+ },
110
+ };
server/sessionStore.js CHANGED
@@ -89,8 +89,24 @@ export const sessionStore = {
89
  saveTempStore().catch(err => console.error('Failed to save temp store:', err));
90
  return s;
91
  },
 
 
 
 
 
 
 
 
92
  deleteTempSession(t, id) { tempStore.get(t)?.sessions.delete(id); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
93
  deleteTempAll(t) { tempStore.get(t)?.sessions.clear(); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
 
 
 
 
 
 
 
 
94
 
95
  /**
96
  * Copy temp sessions into the user's account on login.
@@ -146,6 +162,12 @@ export const sessionStore = {
146
  await this._persist(userClient(accessToken), userId, s).catch(() => {});
147
  return s;
148
  },
 
 
 
 
 
 
149
  async updateUserSession(userId, accessToken, sessionId, patch) {
150
  const user = userCache.get(userId); if (!user) { console.error("No user for " + userId); return null; }
151
  const s = user.sessions.get(sessionId); if (!s) { console.error ("No session found for " + sessionId); return null; }
@@ -228,8 +250,8 @@ export const deviceSessionStore = {
228
  createdAt: new Date().toISOString(), lastSeen: new Date().toISOString(), active: true });
229
  return token;
230
  },
231
- getForUser(uid) { return [...devSessions.values()].filter(s => s.userId === uid); },
232
- revoke(token) { const s = devSessions.get(token); if (s) s.active = false; },
233
  revokeAllExcept(uid, except) {
234
  for (const [t, s] of devSessions) if (s.userId === uid && t !== except) s.active = false;
235
  },
@@ -237,4 +259,4 @@ export const deviceSessionStore = {
237
  const s = devSessions.get(token); if (!s || !s.active) return null;
238
  s.lastSeen = new Date().toISOString(); return s;
239
  },
240
- };
 
89
  saveTempStore().catch(err => console.error('Failed to save temp store:', err));
90
  return s;
91
  },
92
+ restoreTempSession(t, session) {
93
+ const d = this.initTemp(t);
94
+ const restored = JSON.parse(JSON.stringify(session));
95
+ d.sessions.set(restored.id, restored);
96
+ d.lastActive = Date.now();
97
+ saveTempStore().catch(err => console.error('Failed to save temp store:', err));
98
+ return restored;
99
+ },
100
  deleteTempSession(t, id) { tempStore.get(t)?.sessions.delete(id); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
101
  deleteTempAll(t) { tempStore.get(t)?.sessions.clear(); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
102
+ deleteTempSessionEverywhere(id) {
103
+ let changed = false;
104
+ for (const temp of tempStore.values()) {
105
+ if (temp.sessions.delete(id)) changed = true;
106
+ }
107
+ if (changed) saveTempStore().catch(err => console.error('Failed to save temp store:', err));
108
+ return changed;
109
+ },
110
 
111
  /**
112
  * Copy temp sessions into the user's account on login.
 
162
  await this._persist(userClient(accessToken), userId, s).catch(() => {});
163
  return s;
164
  },
165
+ async restoreUserSession(userId, accessToken, session) {
166
+ const restored = JSON.parse(JSON.stringify(session));
167
+ this._ensureUser(userId).sessions.set(restored.id, restored);
168
+ await this._persist(userClient(accessToken), userId, restored).catch(() => {});
169
+ return restored;
170
+ },
171
  async updateUserSession(userId, accessToken, sessionId, patch) {
172
  const user = userCache.get(userId); if (!user) { console.error("No user for " + userId); return null; }
173
  const s = user.sessions.get(sessionId); if (!s) { console.error ("No session found for " + sessionId); return null; }
 
250
  createdAt: new Date().toISOString(), lastSeen: new Date().toISOString(), active: true });
251
  return token;
252
  },
253
+ getForUser(uid) { return [...devSessions.values()].filter(s => s.userId === uid && s.active); },
254
+ revoke(token) { const s = devSessions.get(token); if (s) { s.active = false; return s; } return null; },
255
  revokeAllExcept(uid, except) {
256
  for (const [t, s] of devSessions) if (s.userId === uid && t !== except) s.active = false;
257
  },
 
259
  const s = devSessions.get(token); if (!s || !s.active) return null;
260
  s.lastSeen = new Date().toISOString(); return s;
261
  },
262
+ };
server/systemPromptStore.js ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { loadEncryptedJson, saveEncryptedJson } from './cryptoUtils.js';
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const SYSTEM_PROMPT_FILE = path.resolve(__dirname, '..', 'system prompt.md');
8
+ const DATA_ROOT = '/data/system-prompts';
9
+ const INDEX_FILE = path.join(DATA_ROOT, 'index.json');
10
+ const MAX_PROMPT_LENGTH = 60000;
11
+
12
+ const FALLBACK_PROMPT = `
13
+ # Response formatting
14
+
15
+ - Every response must use HTML <span data-color="{COLOR NAME}">...</span> tags to color main points and headings unless the user asks otherwise.
16
+ - Colors must have meaning and stay consistent across the conversation.
17
+ - Only use these semantic color names: green, pink, blue, red, orange, yellow, purple, teal, gold, coral.
18
+ - Never output explicit black or white colors.
19
+ - Put color spans as close to the text as possible, and do not place markdown syntax inside the span tags.
20
+ - Keep code blocks plain, but color important surrounding headings and key points.
21
+ - Do not over-color responses. Use color intentionally and sparingly.
22
+ - Markdown markers such as #, ##, ###, **, and * must stay outside the color spans.
23
+
24
+ # Core behavior
25
+
26
+ - You are a helpful, friendly AI assistant.
27
+ - Use tools when appropriate to help the user, and if you are told to generate something, use a tool to complete the task.
28
+ - When generating media, do not include URLs because the media is displayed automatically.
29
+ - You can render SVG images by outputting SVG code in a code block tagged exactly like this:
30
+
31
+ \`\`\`svg
32
+ <svg>...</svg>
33
+ \`\`\`
34
+
35
+ - Never use single backslashes.
36
+ - Use markdown for everything other than the color spans.
37
+ - Tables, lists, and other markdown elements are encouraged when they help.
38
+
39
+ # Attachment handling
40
+
41
+ - Large user prompts, text attachments, conversation history, and image attachments may be staged into separate resources on purpose.
42
+ - If notes say attached text was staged separately, or that only the first part of a prompt is inline, do not assume the content is missing, corrupted, or truncated.
43
+ - Treat staged content as available context.
44
+ - Use list_prompt_resources to find staged resources.
45
+ - Use read_prompt_chunk to read staged text exactly.
46
+ - Use load_prompt_images to inspect staged images.
47
+ - Use write_notes to keep a compact working memory after reading several chunks.
48
+ - Before claiming an attachment is incomplete, missing, malformed, or unreadable, first check whether it was staged separately and read the relevant resource.
49
+
50
+ # Memory
51
+
52
+ - Persistent memories must stay short, concrete, and durable.
53
+ - Only save memories that will still help in future chats.
54
+ - Keep each memory to a brief sentence or phrase.
55
+ - At the start of a chat, always check the memories.
56
+ - If the user tells you to remember something, or there is something important to note for future chats, create a new memory.
57
+ - Memories should be brief.
58
+ - Notes are only for session-long memory, so use memories for anything relevant to future chats.
59
+
60
+ # Priorities
61
+
62
+ - Your highest priority is to help the user.
63
+ - Always help with anything ethically right.
64
+ - Make sure your responses are always accurate.
65
+ - If you are not completely sure about something, search the web.
66
+ - If you notice any issue or mistake with your response, correct it with the replace tools.
67
+ - Always answer as correctly as possible, and use search when unsure.
68
+ - Try to minimize the use of * for emphasis. Use it mainly for markdown structure.
69
+
70
+ # Session naming
71
+
72
+ - After you have fully responded to the user, append a session name tag on its own line at the very end of your response, never inside a code block.
73
+ - Only do this on the first response unless the user asks to change the name.
74
+ - The tag must be <session_name>2-4 word title summarizing this conversation</session_name>.
75
+ - Example: <session_name>React State Management</session_name>.
76
+ - A conversation must always be named on the first response.
77
+ - This tag is hidden from the user and is used only to name the chat.
78
+ - Do not mention the tag to the user.
79
+ `.trim();
80
+
81
+ const state = {
82
+ loaded: false,
83
+ prompts: {},
84
+ };
85
+
86
+ let defaultPromptPromise = null;
87
+
88
+ function normalizePrompt(markdown) {
89
+ return String(markdown || '')
90
+ .replace(/\r\n/g, '\n')
91
+ .trim()
92
+ .slice(0, MAX_PROMPT_LENGTH);
93
+ }
94
+
95
+ async function ensureLoaded() {
96
+ if (state.loaded) return;
97
+ const stored = await loadEncryptedJson(INDEX_FILE, 'system-prompts');
98
+ state.prompts = stored?.prompts || {};
99
+ state.loaded = true;
100
+ }
101
+
102
+ async function saveIndex() {
103
+ await saveEncryptedJson(INDEX_FILE, { prompts: state.prompts }, 'system-prompts');
104
+ }
105
+
106
+ async function loadDefaultPrompt() {
107
+ if (!defaultPromptPromise) {
108
+ defaultPromptPromise = fs.readFile(SYSTEM_PROMPT_FILE, 'utf8')
109
+ .then((content) => normalizePrompt(content) || FALLBACK_PROMPT)
110
+ .catch(() => FALLBACK_PROMPT);
111
+ }
112
+ return defaultPromptPromise;
113
+ }
114
+
115
+ function sanitizeRecord(record) {
116
+ if (!record?.markdown) return null;
117
+ return {
118
+ markdown: normalizePrompt(record.markdown),
119
+ updatedAt: record.updatedAt || null,
120
+ };
121
+ }
122
+
123
+ export const systemPromptStore = {
124
+ async getDefaultPrompt() {
125
+ return loadDefaultPrompt();
126
+ },
127
+
128
+ async getUserPrompt(userId) {
129
+ if (!userId) return null;
130
+ await ensureLoaded();
131
+ return sanitizeRecord(state.prompts[userId]);
132
+ },
133
+
134
+ async getResolvedPrompt(userId) {
135
+ const custom = await this.getUserPrompt(userId);
136
+ if (custom?.markdown) return custom.markdown;
137
+ return this.getDefaultPrompt();
138
+ },
139
+
140
+ async getPersonalization(userId) {
141
+ const [defaultPrompt, custom] = await Promise.all([
142
+ this.getDefaultPrompt(),
143
+ this.getUserPrompt(userId),
144
+ ]);
145
+ return {
146
+ defaultPrompt,
147
+ customPrompt: custom?.markdown || null,
148
+ resolvedPrompt: custom?.markdown || defaultPrompt,
149
+ isCustom: !!custom?.markdown,
150
+ updatedAt: custom?.updatedAt || null,
151
+ };
152
+ },
153
+
154
+ async setUserPrompt(userId, markdown) {
155
+ if (!userId) throw new Error('Missing user id');
156
+ const normalized = normalizePrompt(markdown);
157
+ if (!normalized) throw new Error('System prompt cannot be empty');
158
+ await ensureLoaded();
159
+ state.prompts[userId] = {
160
+ markdown: normalized,
161
+ updatedAt: new Date().toISOString(),
162
+ };
163
+ await saveIndex();
164
+ return this.getPersonalization(userId);
165
+ },
166
+
167
+ async resetUserPrompt(userId) {
168
+ if (!userId) throw new Error('Missing user id');
169
+ await ensureLoaded();
170
+ delete state.prompts[userId];
171
+ await saveIndex();
172
+ return this.getPersonalization(userId);
173
+ },
174
+ };
server/webSearchUsageStore.js ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+
4
+ const DATA_DIR = '/data';
5
+ const STORE_FILE = path.join(DATA_DIR, 'web-search-usage.json');
6
+ const APP_TIMEZONE = process.env.APP_TIMEZONE || 'America/New_York';
7
+
8
+ let state = { days: {} };
9
+ let loaded = false;
10
+ let saveChain = Promise.resolve();
11
+
12
+ function todayKey() {
13
+ return new Intl.DateTimeFormat('en-CA', {
14
+ timeZone: APP_TIMEZONE,
15
+ year: 'numeric',
16
+ month: '2-digit',
17
+ day: '2-digit',
18
+ }).format(new Date());
19
+ }
20
+
21
+ function pruneDays() {
22
+ const keepKey = todayKey();
23
+ state.days = Object.fromEntries(
24
+ Object.entries(state.days || {}).filter(([key]) => key === keepKey)
25
+ );
26
+ }
27
+
28
+ async function ensureLoaded() {
29
+ if (loaded) return;
30
+ loaded = true;
31
+ try {
32
+ await fs.mkdir(DATA_DIR, { recursive: true });
33
+ const raw = await fs.readFile(STORE_FILE, 'utf8');
34
+ const parsed = JSON.parse(raw);
35
+ if (parsed && typeof parsed === 'object') state = parsed;
36
+ } catch {}
37
+ pruneDays();
38
+ }
39
+
40
+ function saveState() {
41
+ saveChain = saveChain.then(async () => {
42
+ pruneDays();
43
+ await fs.mkdir(DATA_DIR, { recursive: true });
44
+ await fs.writeFile(STORE_FILE, JSON.stringify(state, null, 2), 'utf8');
45
+ }).catch((err) => {
46
+ console.error('Failed to persist web search usage:', err);
47
+ });
48
+ return saveChain;
49
+ }
50
+
51
+ function getCounterRecord(key) {
52
+ const day = todayKey();
53
+ if (!state.days[day]) state.days[day] = {};
54
+ if (!state.days[day][key]) state.days[day][key] = 0;
55
+ return {
56
+ day,
57
+ used: state.days[day][key],
58
+ set(nextValue) {
59
+ state.days[day][key] = nextValue;
60
+ },
61
+ };
62
+ }
63
+
64
+ export async function getWebSearchUsage(key, limit = 15) {
65
+ await ensureLoaded();
66
+ const record = getCounterRecord(key);
67
+ return {
68
+ limit,
69
+ used: record.used,
70
+ remaining: Math.max(0, limit - record.used),
71
+ window: record.day,
72
+ period: 'daily',
73
+ };
74
+ }
75
+
76
+ export async function consumeWebSearchUsage(key, limit = 15) {
77
+ await ensureLoaded();
78
+ const record = getCounterRecord(key);
79
+ if (record.used >= limit) {
80
+ return {
81
+ allowed: false,
82
+ ...(await getWebSearchUsage(key, limit)),
83
+ };
84
+ }
85
+ record.set(record.used + 1);
86
+ await saveState();
87
+ return {
88
+ allowed: true,
89
+ ...(await getWebSearchUsage(key, limit)),
90
+ };
91
+ }
server/wsHandler.js CHANGED
@@ -9,8 +9,14 @@ import {
9
  getUserProfile, setUsername, getSubscriptionInfo,
10
  getTierConfig, getUsageInfo,
11
  } from './auth.js';
12
- import { streamChat, extractSessionName } from './chatStream.js';
 
 
 
 
 
13
  import crypto from 'crypto';
 
14
 
15
  /**
16
  * Message Structure: Tree-based with versioned tails
@@ -40,9 +46,36 @@ import crypto from 'crypto';
40
  */
41
 
42
  const activeStreams = new Map();
 
 
 
 
43
 
44
  initGuestRequestLimiter().catch(err => console.error('Failed to initialize guest request limiter:', err));
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  export async function handleWsMessage(ws, msg, wsClients) {
47
  const client = wsClients.get(ws); if (!client) return;
48
  // Require turnstile verification for most message types
@@ -50,6 +83,23 @@ export async function handleWsMessage(ws, msg, wsClients) {
50
  return safeSend(ws, { type: 'error', message: 'turnstile:required' });
51
  }
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  const h = handlers[msg.type];
54
  if (h) return h(ws, msg, client, wsClients);
55
  safeSend(ws, { type: 'error', message: `Unknown: ${msg.type}` });
@@ -77,13 +127,30 @@ const handlers = {
77
  },
78
 
79
  'auth:login': async (ws, msg, client, wsClients) => {
80
- const { accessToken, tempId: clientTempId } = msg;
81
  if (!accessToken) return safeSend(ws, { type: 'auth:error', message: 'Missing token' });
82
  const user = await verifySupabaseToken(accessToken);
83
  if (!user) return safeSend(ws, { type: 'auth:error', message: 'Invalid token' });
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  client.userId = user.id; client.accessToken = accessToken; client.authenticated = true;
86
- client.deviceToken = deviceSessionStore.create(user.id, client.ip, client.userAgent);
87
  sessionStore.markOnline(user.id, ws);
88
 
89
  if (clientTempId) client.tempId = clientTempId;
@@ -101,13 +168,17 @@ const handlers = {
101
  deviceToken: client.deviceToken, sessions: sessions.map(ser), settings, profile, subscription };
102
  safeSend(ws, authOkMsg);
103
 
104
- bcast(wsClients, user.id, { type: 'auth:newLogin', message: 'New login on your account.',
105
- ip: client.ip, userAgent: client.userAgent, timestamp: new Date().toISOString() }, ws);
 
 
106
  },
107
 
108
  'auth:logout': (ws, msg, client) => {
 
109
  if (client.deviceToken) deviceSessionStore.revoke(client.deviceToken);
110
  Object.assign(client, { userId: null, authenticated: false, accessToken: null, deviceToken: null });
 
111
  safeSend(ws, { type: 'auth:loggedOut' });
112
  },
113
 
@@ -134,15 +205,36 @@ const handlers = {
134
  },
135
 
136
  'sessions:delete': async (ws, msg, client) => {
137
- if (client.userId) await sessionStore.deleteUserSession(client.userId, client.accessToken, msg.sessionId);
138
- else sessionStore.deleteTempSession(client.tempId, msg.sessionId);
 
 
 
 
 
 
 
 
 
 
 
139
  safeSend(ws, { type: 'sessions:deleted', sessionId: msg.sessionId });
 
140
  },
141
 
142
  'sessions:deleteAll': async (ws, msg, client) => {
 
 
 
 
 
 
 
 
143
  if (client.userId) await sessionStore.deleteAllUserSessions(client.userId, client.accessToken);
144
  else sessionStore.deleteTempAll(client.tempId);
145
  safeSend(ws, { type: 'sessions:deletedAll' });
 
146
  },
147
 
148
  'sessions:rename': async (ws, msg, client) => {
@@ -175,8 +267,34 @@ const handlers = {
175
  safeSend(ws, { type: 'sessions:imported', session: ser(s) });
176
  },
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  'chat:send': async (ws, msg, client) => {
179
  const { sessionId, content, tools } = msg;
 
180
  if (!client.userId) {
181
  const allowed = await consumeGuestRequest(client.ip || 'unknown');
182
  if (!allowed) return safeSend(ws, { type: 'guest:rateLimit', message: 'Guest request limit exceeded' });
@@ -200,27 +318,48 @@ const handlers = {
200
  const rootMessage = session.history?.[0];
201
  const flatHistory = rootMessage ? extractFlatHistory(rootMessage) : [];
202
 
 
 
 
 
 
 
 
 
 
 
 
203
  await streamChat({
204
  sessionId,
205
  model: session.model,
206
- history: flatHistory,
207
- userMessage: content,
208
  tools: tools || {},
209
- accessToken: client.accessToken,
210
- clientId: msg.clientId,
 
 
 
211
  abortSignal: abort.signal,
212
  onToken(t) { fullText += t; safeSend(ws, { type: 'chat:token', token: t, sessionId }); },
213
  onToolCall(call) {
214
  safeSend(ws, { type: 'chat:toolCall', call, sessionId });
215
  if (call.state === 'resolved' || call.state === 'canceled') toolCallsCollected.push(call);
216
  },
217
- onNewAsset(asset) { safeSend(ws, { type: 'chat:asset', asset, sessionId }); assetsCollected.push(asset); },
218
- async onDone(text, toolCalls, aborted, sessionNameFromTag) {
 
 
 
 
 
 
 
219
  activeStreams.delete(ws);
220
  const finalText = text || fullText;
221
-
222
  // Only create user entry if content was actually provided
223
- const hasContent = content !== undefined && content !== null && content !== '' &&
224
  !(Array.isArray(content) && content.length === 0);
225
  const userEntry = hasContent
226
  ? buildEntry('user', content)
@@ -231,27 +370,48 @@ const handlers = {
231
  const resolved = resolvedMap.get(c.id) || {};
232
  return { ...c, state: resolved.state || 'resolved', result: resolved.result };
233
  });
234
- const asstEntry = buildEntry('assistant', finalText, mergedCalls);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
 
236
- // Rebuild tree structure with new messages appended
237
- let newRootMessage = rootMessage ? validateAndRepairTree(JSON.parse(JSON.stringify(rootMessage))) : null;
238
-
239
  if (!newRootMessage) {
240
  // First message in session - must have user entry
241
  if (!userEntry) return safeSend(ws, { type: 'error', message: 'No content for first message' });
242
  newRootMessage = userEntry;
243
- const asstWrap = { ...asstEntry };
244
- newRootMessage.versions[0].tail = [asstWrap];
 
245
  } else {
246
- // Append to current tail
247
- const currentVerIdx = newRootMessage.currentVersionIdx ?? 0;
248
- let currentTail = newRootMessage.versions[currentVerIdx].tail || [];
249
- currentTail = JSON.parse(JSON.stringify(currentTail));
250
- if (userEntry) {
251
- currentTail.push(userEntry);
252
- }
253
- currentTail.push(asstEntry);
254
- newRootMessage.versions[currentVerIdx].tail = currentTail;
255
  }
256
 
257
  const newHistory = [newRootMessage];
@@ -266,13 +426,13 @@ const handlers = {
266
  if (client.userId)
267
  await sessionStore.updateUserSession(client.userId, client.accessToken, sessionId, { history: newHistory, name: newName });
268
  else sessionStore.updateTempSession(client.tempId, sessionId, { history: newHistory, name: newName });
269
-
270
  safeSend(ws, { type: aborted ? 'chat:aborted' : 'chat:done', sessionId, name: newName, history: extractFlatHistory(newRootMessage) });
271
  },
272
- onError(err) {
273
  activeStreams.delete(ws);
274
  console.error('streamChat error:', err);
275
- safeSend(ws, { type: 'chat:error', error: String(err), sessionId });
276
  },
277
  });
278
  },
@@ -297,8 +457,18 @@ const handlers = {
297
  }
298
 
299
  // Find the target message in the tree and add new version
300
- const newRoot = validateAndRepairTree(JSON.parse(JSON.stringify(rootMessage)));
 
 
 
301
  const found = findAndUpdateMessage(newRoot, targetMsg.id, (msgInTree) => {
 
 
 
 
 
 
 
302
  // Add new version with EMPTY tail (no responses yet for this edited version)
303
  msgInTree.versions.push({
304
  content: newContent,
@@ -345,7 +515,7 @@ const handlers = {
345
  if (!targetMsg || !targetMsg.versions || versionIdx >= targetMsg.versions.length) return;
346
 
347
  // Find and update the message in tree, switching to specified version
348
- const newRoot = validateAndRepairTree(JSON.parse(JSON.stringify(rootMessage)));
349
  const found = findAndUpdateMessage(newRoot, targetMsg.id, (msgInTree) => {
350
  msgInTree.currentVersionIdx = versionIdx;
351
  msgInTree.content = msgInTree.versions[versionIdx].content;
@@ -365,6 +535,162 @@ const handlers = {
365
  safeSend(ws, { type: 'chat:versionSelected', sessionId, messageId: targetMsg.id, messageIndex, history: extractFlatHistory(newRoot) });
366
  },
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  'settings:get': async (ws, msg, client) => {
369
  const s = client.userId
370
  ? await getUserSettings(client.userId, client.accessToken)
@@ -378,6 +704,72 @@ const handlers = {
378
  bcast(wsClients, client.userId, { type: 'settings:updated', settings: msg.settings }, ws);
379
  },
380
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  'account:getProfile': async (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:profile', profile: await getUserProfile(c.userId, c.accessToken) }); },
382
  'account:setUsername': async (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:usernameResult', ...await setUsername(c.userId, c.accessToken, msg.username) }); },
383
  'account:getSubscription': async (ws, msg, c) => {
@@ -385,26 +777,80 @@ const handlers = {
385
  const subInfo = await getSubscriptionInfo(c.accessToken);
386
  safeSend(ws, { type: 'account:subscription', info: subInfo });
387
  },
388
- 'account:getUsage': async (ws, msg, c) => { safeSend(ws, { type: 'account:usage', usage: await getUsageInfo(c.accessToken) }); },
389
  'account:getTierConfig': async (ws) => { safeSend(ws, { type: 'account:tierConfig', config: await getTierConfig() }); },
390
  'account:getSessions': (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:deviceSessions', sessions: deviceSessionStore.getForUser(c.userId), currentToken: c.deviceToken }); },
391
- 'account:revokeSession': (ws, msg, c) => { if (!c.userId) return; deviceSessionStore.revoke(msg.token); safeSend(ws, { type: 'account:sessionRevoked', token: msg.token }); },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  'account:revokeAllOthers': (ws, msg, c, wsClients) => {
393
  if (!c.userId) return;
394
  deviceSessionStore.revokeAllExcept(c.userId, c.deviceToken);
395
- for (const [ows, oc] of wsClients)
396
- if (oc.userId === c.userId && ows !== ws) safeSend(ows, { type: 'auth:forcedLogout', reason: 'Session revoked by another device' });
 
 
 
 
 
 
 
 
 
 
 
 
 
397
  safeSend(ws, { type: 'account:allOthersRevoked' });
398
  },
399
  };
400
 
401
  function ser(s) { return { id: s.id, name: s.name, created: s.created, history: s.history || [], model: s.model }; }
402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  function generateMessageId() {
404
  return `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
405
  }
406
 
407
- function buildEntry(role, content, toolCalls = []) {
408
  const normalizedCalls = toolCalls.map(c => ({
409
  id: c.id,
410
  name: c.name || c.function?.name,
@@ -413,17 +859,151 @@ function buildEntry(role, content, toolCalls = []) {
413
  result: c.result,
414
  }));
415
  const validContent = (content === undefined || content === null) ? '' : content;
 
 
 
 
 
 
 
 
416
  return {
417
  id: generateMessageId(),
418
  role,
419
  content: validContent,
420
  timestamp: Date.now(),
421
- versions: [{ content: validContent, tail: [], timestamp: Date.now() }],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  currentVersionIdx: 0,
423
- ...(normalizedCalls.length ? { toolCalls: normalizedCalls } : {})
424
  };
425
  }
426
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  /**
428
  * Validate and repair tree structure after cloning/modification
429
  * Ensures all messages and versions have valid content property
@@ -454,6 +1034,84 @@ function validateAndRepairTree(rootMessage) {
454
  return rootMessage;
455
  }
456
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  function extractFlatHistory(rootMessage) {
458
  if (!rootMessage) return [];
459
 
@@ -462,7 +1120,7 @@ function extractFlatHistory(rootMessage) {
462
  if (msg.content === undefined || msg.content === null) {
463
  msg.content = '';
464
  }
465
- return msg;
466
  };
467
 
468
  const history = [ensureValidContent(rootMessage)];
@@ -482,12 +1140,16 @@ function extractFlatHistory(rootMessage) {
482
 
483
  if (currentTail && Array.isArray(currentTail)) {
484
  const walkTail = (tail) => {
485
- for (const msg of tail) {
 
486
  history.push(ensureValidContent(msg));
487
  const ver = msg.versions?.[msg.currentVersionIdx ?? 0];
488
  if (ver?.tail && Array.isArray(ver.tail)) {
489
  walkTail(ver.tail);
490
  }
 
 
 
491
  }
492
  };
493
  walkTail(currentTail);
@@ -495,6 +1157,31 @@ function extractFlatHistory(rootMessage) {
495
  return history;
496
  }
497
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
  function findAndUpdateMessage(rootMessage, targetId, updateFn) {
499
  if (rootMessage.id === targetId) {
500
  updateFn(rootMessage);
 
9
  getUserProfile, setUsername, getSubscriptionInfo,
10
  getTierConfig, getUsageInfo,
11
  } from './auth.js';
12
+ import { streamChat } from './chatStream.js';
13
+ import { mediaStore } from './mediaStore.js';
14
+ import { memoryStore } from './memoryStore.js';
15
+ import { chatTrashStore } from './chatTrashStore.js';
16
+ import { systemPromptStore } from './systemPromptStore.js';
17
+ import { getWebSearchUsage } from './webSearchUsageStore.js';
18
  import crypto from 'crypto';
19
+ import path from 'path';
20
 
21
  /**
22
  * Message Structure: Tree-based with versioned tails
 
46
  */
47
 
48
  const activeStreams = new Map();
49
+ const VERSION_META_FIELDS = ['toolCalls', 'responseEdits', 'responseSegments', 'error'];
50
+ const CONTINUE_ASSISTANT_PROMPT =
51
+ 'Continue your previous response exactly where it left off. Do not restart, summarize, or repeat the opening. Preserve the same formatting and only add the missing continuation.';
52
+ const FREE_WEB_SEARCH_LIMIT = 15;
53
 
54
  initGuestRequestLimiter().catch(err => console.error('Failed to initialize guest request limiter:', err));
55
 
56
+ function usageOwnerKey(client, clientId = '') {
57
+ if (client?.userId) return `user:${client.userId}`;
58
+ return `guest:${clientId || client?.tempId || 'anonymous'}`;
59
+ }
60
+
61
+ function isFreeSearchPlan(client, usageInfo) {
62
+ if (!client?.userId) return true;
63
+ const planKey = usageInfo?.plan_key || usageInfo?.planKey || null;
64
+ return planKey === 'free';
65
+ }
66
+
67
+ async function buildUsagePayload(client, clientId = '') {
68
+ const usageInfo = await getUsageInfo(client?.accessToken, clientId);
69
+ const webSearchDaily = await getWebSearchUsage(usageOwnerKey(client, clientId), FREE_WEB_SEARCH_LIMIT);
70
+ return {
71
+ ...(usageInfo || {}),
72
+ usage: {
73
+ ...(usageInfo?.usage || {}),
74
+ webSearchDaily,
75
+ },
76
+ };
77
+ }
78
+
79
  export async function handleWsMessage(ws, msg, wsClients) {
80
  const client = wsClients.get(ws); if (!client) return;
81
  // Require turnstile verification for most message types
 
83
  return safeSend(ws, { type: 'error', message: 'turnstile:required' });
84
  }
85
 
86
+ const bypassDeviceValidation = new Set([
87
+ 'ping',
88
+ 'turnstile:verify',
89
+ 'auth:login',
90
+ 'auth:guest',
91
+ 'auth:logout',
92
+ ]);
93
+ if (client.userId && client.deviceToken && !bypassDeviceValidation.has(msg.type)) {
94
+ const activeDeviceSession = deviceSessionStore.validate(client.deviceToken);
95
+ if (!activeDeviceSession) {
96
+ const priorUserId = client.userId;
97
+ Object.assign(client, { userId: null, authenticated: false, accessToken: null, deviceToken: null });
98
+ sessionStore.markOffline(priorUserId, ws);
99
+ return safeSend(ws, { type: 'auth:forcedLogout', reason: 'Session revoked by another device' });
100
+ }
101
+ }
102
+
103
  const h = handlers[msg.type];
104
  if (h) return h(ws, msg, client, wsClients);
105
  safeSend(ws, { type: 'error', message: `Unknown: ${msg.type}` });
 
127
  },
128
 
129
  'auth:login': async (ws, msg, client, wsClients) => {
130
+ const { accessToken, tempId: clientTempId, deviceToken: requestedDeviceToken } = msg;
131
  if (!accessToken) return safeSend(ws, { type: 'auth:error', message: 'Missing token' });
132
  const user = await verifySupabaseToken(accessToken);
133
  if (!user) return safeSend(ws, { type: 'auth:error', message: 'Invalid token' });
134
 
135
+ let nextDeviceToken = null;
136
+ let reusedDeviceSession = false;
137
+ if (requestedDeviceToken) {
138
+ const existingDevice = deviceSessionStore.validate(requestedDeviceToken);
139
+ if (existingDevice?.userId === user.id) {
140
+ existingDevice.ip = client.ip;
141
+ existingDevice.userAgent = client.userAgent;
142
+ nextDeviceToken = existingDevice.token;
143
+ reusedDeviceSession = true;
144
+ }
145
+ }
146
+ if (!nextDeviceToken) {
147
+ nextDeviceToken = deviceSessionStore.create(user.id, client.ip, client.userAgent);
148
+ }
149
+ if (client.deviceToken && client.deviceToken !== nextDeviceToken) {
150
+ deviceSessionStore.revoke(client.deviceToken);
151
+ }
152
  client.userId = user.id; client.accessToken = accessToken; client.authenticated = true;
153
+ client.deviceToken = nextDeviceToken;
154
  sessionStore.markOnline(user.id, ws);
155
 
156
  if (clientTempId) client.tempId = clientTempId;
 
168
  deviceToken: client.deviceToken, sessions: sessions.map(ser), settings, profile, subscription };
169
  safeSend(ws, authOkMsg);
170
 
171
+ if (!reusedDeviceSession) {
172
+ bcast(wsClients, user.id, { type: 'auth:newLogin', message: 'New login on your account.',
173
+ ip: client.ip, userAgent: client.userAgent, timestamp: new Date().toISOString() }, ws);
174
+ }
175
  },
176
 
177
  'auth:logout': (ws, msg, client) => {
178
+ const priorUserId = client.userId;
179
  if (client.deviceToken) deviceSessionStore.revoke(client.deviceToken);
180
  Object.assign(client, { userId: null, authenticated: false, accessToken: null, deviceToken: null });
181
+ if (priorUserId) sessionStore.markOffline(priorUserId, ws);
182
  safeSend(ws, { type: 'auth:loggedOut' });
183
  },
184
 
 
205
  },
206
 
207
  'sessions:delete': async (ws, msg, client) => {
208
+ const owner = getClientOwner(client);
209
+ const session = client.userId
210
+ ? sessionStore.getUserSession(client.userId, msg.sessionId)
211
+ : sessionStore.getTempSession(client.tempId, msg.sessionId);
212
+ if (!session) return safeSend(ws, { type: 'error', message: 'Session not found' });
213
+
214
+ await chatTrashStore.add(owner, JSON.parse(JSON.stringify(session)));
215
+ if (client.userId) {
216
+ await sessionStore.deleteUserSession(client.userId, client.accessToken, msg.sessionId);
217
+ sessionStore.deleteTempSessionEverywhere(msg.sessionId);
218
+ } else {
219
+ sessionStore.deleteTempSession(client.tempId, msg.sessionId);
220
+ }
221
  safeSend(ws, { type: 'sessions:deleted', sessionId: msg.sessionId });
222
+ safeSend(ws, { type: 'trash:chats:changed' });
223
  },
224
 
225
  'sessions:deleteAll': async (ws, msg, client) => {
226
+ const owner = getClientOwner(client);
227
+ const sessions = client.userId
228
+ ? sessionStore.getUserSessions(client.userId)
229
+ : sessionStore.getTempSessions(client.tempId);
230
+ for (const session of sessions) {
231
+ await chatTrashStore.add(owner, JSON.parse(JSON.stringify(session)));
232
+ if (client.userId) sessionStore.deleteTempSessionEverywhere(session.id);
233
+ }
234
  if (client.userId) await sessionStore.deleteAllUserSessions(client.userId, client.accessToken);
235
  else sessionStore.deleteTempAll(client.tempId);
236
  safeSend(ws, { type: 'sessions:deletedAll' });
237
+ safeSend(ws, { type: 'trash:chats:changed' });
238
  },
239
 
240
  'sessions:rename': async (ws, msg, client) => {
 
267
  safeSend(ws, { type: 'sessions:imported', session: ser(s) });
268
  },
269
 
270
+ 'trash:chats:list': async (ws, msg, client) => {
271
+ const owner = getClientOwner(client);
272
+ const items = await chatTrashStore.list(owner);
273
+ safeSend(ws, { type: 'trash:chats:list', items });
274
+ },
275
+
276
+ 'trash:chats:restore': async (ws, msg, client) => {
277
+ const owner = getClientOwner(client);
278
+ const restored = await chatTrashStore.restore(owner, msg.ids || []);
279
+ const sessions = [];
280
+ for (const snapshot of restored) {
281
+ const restoredSession = await restoreDeletedSession(client, snapshot);
282
+ if (restoredSession) sessions.push(ser(restoredSession));
283
+ }
284
+ safeSend(ws, { type: 'trash:chats:restored', sessions });
285
+ safeSend(ws, { type: 'trash:chats:changed' });
286
+ },
287
+
288
+ 'trash:chats:deleteForever': async (ws, msg, client) => {
289
+ const owner = getClientOwner(client);
290
+ const removedIds = await chatTrashStore.deleteForever(owner, msg.ids || []);
291
+ safeSend(ws, { type: 'trash:chats:deletedForever', ids: removedIds });
292
+ safeSend(ws, { type: 'trash:chats:changed' });
293
+ },
294
+
295
  'chat:send': async (ws, msg, client) => {
296
  const { sessionId, content, tools } = msg;
297
+ const owner = getClientOwner(client);
298
  if (!client.userId) {
299
  const allowed = await consumeGuestRequest(client.ip || 'unknown');
300
  if (!allowed) return safeSend(ws, { type: 'guest:rateLimit', message: 'Guest request limit exceeded' });
 
318
  const rootMessage = session.history?.[0];
319
  const flatHistory = rootMessage ? extractFlatHistory(rootMessage) : [];
320
 
321
+ if (Array.isArray(msg.linkedMediaIds) && msg.linkedMediaIds.length) {
322
+ await mediaStore.attachToSession(owner, msg.linkedMediaIds, sessionId).catch((err) => {
323
+ console.error('Failed to link uploaded media to session:', err);
324
+ });
325
+ }
326
+
327
+ const usagePayload = await buildUsagePayload(client, msg.clientId || '');
328
+ const webSearchLimit = isFreeSearchPlan(client, usagePayload)
329
+ ? { key: usageOwnerKey(client, msg.clientId || ''), limit: FREE_WEB_SEARCH_LIMIT }
330
+ : null;
331
+
332
  await streamChat({
333
  sessionId,
334
  model: session.model,
335
+ history: flatHistory,
336
+ userMessage: content,
337
  tools: tools || {},
338
+ accessToken: client.accessToken,
339
+ clientId: msg.clientId,
340
+ webSearchLimit,
341
+ owner,
342
+ sessionName: session.name,
343
  abortSignal: abort.signal,
344
  onToken(t) { fullText += t; safeSend(ws, { type: 'chat:token', token: t, sessionId }); },
345
  onToolCall(call) {
346
  safeSend(ws, { type: 'chat:toolCall', call, sessionId });
347
  if (call.state === 'resolved' || call.state === 'canceled') toolCallsCollected.push(call);
348
  },
349
+ onNewAsset(asset) {
350
+ safeSend(ws, { type: 'chat:asset', asset, sessionId });
351
+ assetsCollected.push(asset);
352
+ safeSend(ws, { type: 'media:changed' });
353
+ },
354
+ onDraftEdit(edit, draftText) {
355
+ safeSend(ws, { type: 'chat:draftEdited', edit, text: draftText, sessionId });
356
+ },
357
+ async onDone(text, toolCalls, aborted, sessionNameFromTag, responseEdits = [], responseSegments = []) {
358
  activeStreams.delete(ws);
359
  const finalText = text || fullText;
360
+
361
  // Only create user entry if content was actually provided
362
+ const hasContent = content !== undefined && content !== null && content !== '' &&
363
  !(Array.isArray(content) && content.length === 0);
364
  const userEntry = hasContent
365
  ? buildEntry('user', content)
 
370
  const resolved = resolvedMap.get(c.id) || {};
371
  return { ...c, state: resolved.state || 'resolved', result: resolved.result };
372
  });
373
+ const asstEntry = buildEntry('assistant', finalText, mergedCalls, {
374
+ responseEdits,
375
+ responseSegments,
376
+ });
377
+
378
+ const mediaEntries = assetsCollected.map((asset) =>
379
+ buildMediaEntry(asset.role, {
380
+ assetId: asset.id,
381
+ mimeType: asset.mimeType,
382
+ name: asset.name,
383
+ })
384
+ );
385
+
386
+ const generatedFiles = extractAssistantGeneratedFiles(finalText);
387
+ for (const file of generatedFiles) {
388
+ await mediaStore.storeBuffer(owner, {
389
+ name: file.name,
390
+ mimeType: file.mimeType,
391
+ buffer: file.buffer,
392
+ sessionId,
393
+ source: 'assistant_generated',
394
+ kind: file.kind,
395
+ }).catch((err) => console.error('Failed to store generated text asset:', err));
396
+ }
397
+ if (generatedFiles.length) safeSend(ws, { type: 'media:changed' });
398
+
399
+ // Rebuild tree structure with new messages appended to the active branch leaf.
400
+ let newRootMessage = rootMessage ? cloneAndRepairTree(rootMessage) : null;
401
 
 
 
 
402
  if (!newRootMessage) {
403
  // First message in session - must have user entry
404
  if (!userEntry) return safeSend(ws, { type: 'error', message: 'No content for first message' });
405
  newRootMessage = userEntry;
406
+ newRootMessage.versions[0].tail = [{ ...asstEntry }, ...mediaEntries];
407
+ } else if (userEntry) {
408
+ appendConversationTurn(newRootMessage, userEntry, asstEntry, mediaEntries);
409
  } else {
410
+ const appendedEntries = [
411
+ asstEntry,
412
+ ...mediaEntries,
413
+ ];
414
+ appendEntriesToActiveLeaf(newRootMessage, appendedEntries);
 
 
 
 
415
  }
416
 
417
  const newHistory = [newRootMessage];
 
426
  if (client.userId)
427
  await sessionStore.updateUserSession(client.userId, client.accessToken, sessionId, { history: newHistory, name: newName });
428
  else sessionStore.updateTempSession(client.tempId, sessionId, { history: newHistory, name: newName });
429
+
430
  safeSend(ws, { type: aborted ? 'chat:aborted' : 'chat:done', sessionId, name: newName, history: extractFlatHistory(newRootMessage) });
431
  },
432
+ onError(err) {
433
  activeStreams.delete(ws);
434
  console.error('streamChat error:', err);
435
+ safeSend(ws, { type: 'chat:error', error: String(err), sessionId });
436
  },
437
  });
438
  },
 
457
  }
458
 
459
  // Find the target message in the tree and add new version
460
+ const newRoot = cloneAndRepairTree(rootMessage);
461
+ const context = findMessageContext(newRoot, targetMsg.id);
462
+ if (!context?.message) return safeSend(ws, { type: 'error', message: 'Message not found in tree' });
463
+
464
  const found = findAndUpdateMessage(newRoot, targetMsg.id, (msgInTree) => {
465
+ if (msgInTree.role === 'user' && Array.isArray(context.parentTail) && context.index >= 0) {
466
+ const trailing = context.parentTail.splice(context.index + 1);
467
+ if (trailing.length) {
468
+ const currentVersion = getActiveVersion(msgInTree);
469
+ currentVersion.tail = [...(currentVersion.tail || []), ...trailing];
470
+ }
471
+ }
472
  // Add new version with EMPTY tail (no responses yet for this edited version)
473
  msgInTree.versions.push({
474
  content: newContent,
 
515
  if (!targetMsg || !targetMsg.versions || versionIdx >= targetMsg.versions.length) return;
516
 
517
  // Find and update the message in tree, switching to specified version
518
+ const newRoot = cloneAndRepairTree(rootMessage);
519
  const found = findAndUpdateMessage(newRoot, targetMsg.id, (msgInTree) => {
520
  msgInTree.currentVersionIdx = versionIdx;
521
  msgInTree.content = msgInTree.versions[versionIdx].content;
 
535
  safeSend(ws, { type: 'chat:versionSelected', sessionId, messageId: targetMsg.id, messageIndex, history: extractFlatHistory(newRoot) });
536
  },
537
 
538
+ 'chat:assistantAction': async (ws, msg, client) => {
539
+ const { sessionId, messageIndex } = msg;
540
+ const action = msg.action === 'continue' ? 'continue' : 'regenerate';
541
+ const session = client.userId
542
+ ? sessionStore.getUserSession(client.userId, sessionId)
543
+ : sessionStore.getTempSession(client.tempId, sessionId);
544
+ if (!session) return safeSend(ws, { type: 'error', message: 'Session not found' });
545
+
546
+ const rootMessage = session.history?.[0];
547
+ if (!rootMessage) return safeSend(ws, { type: 'error', message: 'No history' });
548
+
549
+ const flatHistory = extractFlatHistory(rootMessage);
550
+ const targetMsg = flatHistory[messageIndex];
551
+ if (!targetMsg || targetMsg.role !== 'assistant') {
552
+ return safeSend(ws, { type: 'error', message: 'Assistant message not found' });
553
+ }
554
+
555
+ if (activeStreams.has(ws)) activeStreams.get(ws).abort();
556
+ const abort = new AbortController();
557
+ activeStreams.set(ws, abort);
558
+
559
+ const owner = getClientOwner(client);
560
+ const historyBeforeTarget = flatHistory.slice(0, messageIndex);
561
+ const baseAssistantText = stripSessionTagText(targetMsg.content || '');
562
+ const actionHistory = action === 'continue'
563
+ ? [...historyBeforeTarget, { role: 'assistant', content: baseAssistantText }]
564
+ : historyBeforeTarget;
565
+ const actionUserMessage = action === 'continue' ? CONTINUE_ASSISTANT_PROMPT : null;
566
+
567
+ safeSend(ws, {
568
+ type: 'chat:start',
569
+ sessionId,
570
+ streamKind: 'assistantAction',
571
+ action,
572
+ messageIndex,
573
+ prefillText: action === 'continue' ? baseAssistantText : '',
574
+ });
575
+
576
+ let fullText = '';
577
+ const assetsCollected = [];
578
+ const toolCallsCollected = [];
579
+ const usagePayload = await buildUsagePayload(client, msg.clientId || '');
580
+ const webSearchLimit = isFreeSearchPlan(client, usagePayload)
581
+ ? { key: usageOwnerKey(client, msg.clientId || ''), limit: FREE_WEB_SEARCH_LIMIT }
582
+ : null;
583
+
584
+ await streamChat({
585
+ sessionId,
586
+ model: session.model,
587
+ history: actionHistory,
588
+ userMessage: actionUserMessage,
589
+ tools: msg.tools || {},
590
+ accessToken: client.accessToken,
591
+ clientId: msg.clientId,
592
+ webSearchLimit,
593
+ owner,
594
+ sessionName: session.name,
595
+ abortSignal: abort.signal,
596
+ onToken(t) {
597
+ fullText += t;
598
+ safeSend(ws, { type: 'chat:token', token: t, sessionId });
599
+ },
600
+ onToolCall(call) {
601
+ safeSend(ws, { type: 'chat:toolCall', call, sessionId });
602
+ if (call.state === 'resolved' || call.state === 'canceled') toolCallsCollected.push(call);
603
+ },
604
+ onNewAsset(asset) {
605
+ safeSend(ws, { type: 'chat:asset', asset, sessionId });
606
+ assetsCollected.push(asset);
607
+ safeSend(ws, { type: 'media:changed' });
608
+ },
609
+ onDraftEdit(edit, draftText) {
610
+ safeSend(ws, { type: 'chat:draftEdited', edit, text: draftText, sessionId });
611
+ },
612
+ async onDone(text, toolCalls, aborted, sessionNameFromTag, responseEdits = [], responseSegments = []) {
613
+ activeStreams.delete(ws);
614
+ if (aborted) {
615
+ return safeSend(ws, { type: 'chat:aborted', sessionId });
616
+ }
617
+
618
+ const rawAssistantText = text || fullText;
619
+ const resolvedMap = new Map(toolCallsCollected.map((call) => [call.id, call]));
620
+ const mergedCalls = (toolCalls || []).map((call) => {
621
+ const resolved = resolvedMap.get(call.id) || {};
622
+ return { ...call, state: resolved.state || 'resolved', result: resolved.result };
623
+ });
624
+
625
+ let finalText = rawAssistantText;
626
+ let finalSegments = responseSegments;
627
+
628
+ if (action === 'continue') {
629
+ const { continuationText, overlapLength } = stripContinuationOverlap(baseAssistantText, rawAssistantText);
630
+ finalText = baseAssistantText + continuationText;
631
+ finalSegments = [
632
+ ...(baseAssistantText ? [{ type: 'text', text: baseAssistantText }] : []),
633
+ ...trimLeadingTextFromSegments(responseSegments, overlapLength),
634
+ ];
635
+ }
636
+
637
+ const mediaEntries = assetsCollected.map((asset) =>
638
+ buildMediaEntry(asset.role, {
639
+ assetId: asset.id,
640
+ mimeType: asset.mimeType,
641
+ name: asset.name,
642
+ })
643
+ );
644
+
645
+ const generatedFiles = extractAssistantGeneratedFiles(finalText);
646
+ for (const file of generatedFiles) {
647
+ await mediaStore.storeBuffer(owner, {
648
+ name: file.name,
649
+ mimeType: file.mimeType,
650
+ buffer: file.buffer,
651
+ sessionId,
652
+ source: 'assistant_generated',
653
+ kind: file.kind,
654
+ }).catch((err) => console.error('Failed to store generated text asset:', err));
655
+ }
656
+ if (generatedFiles.length) safeSend(ws, { type: 'media:changed' });
657
+
658
+ const newRoot = cloneAndRepairTree(rootMessage);
659
+ const found = findAndUpdateMessage(newRoot, targetMsg.id, (msgInTree) => {
660
+ const nextVersion = buildVersionRecord(finalText, {
661
+ tail: mediaEntries,
662
+ toolCalls: mergedCalls,
663
+ responseEdits,
664
+ responseSegments: finalSegments,
665
+ });
666
+ applyVersionToMessage(msgInTree, nextVersion);
667
+ });
668
+ if (!found) {
669
+ return safeSend(ws, { type: 'error', message: 'Assistant branch could not be updated' });
670
+ }
671
+
672
+ let newName = session.name;
673
+ if (sessionNameFromTag) {
674
+ newName = sessionNameFromTag;
675
+ }
676
+
677
+ const newHistory = [newRoot];
678
+ if (client.userId) {
679
+ await sessionStore.updateUserSession(client.userId, client.accessToken, sessionId, { history: newHistory, name: newName });
680
+ } else {
681
+ sessionStore.updateTempSession(client.tempId, sessionId, { history: newHistory, name: newName });
682
+ }
683
+
684
+ safeSend(ws, { type: 'chat:done', sessionId, name: newName, history: extractFlatHistory(newRoot) });
685
+ },
686
+ onError(err) {
687
+ activeStreams.delete(ws);
688
+ console.error('assistant action streamChat error:', err);
689
+ safeSend(ws, { type: 'chat:error', error: String(err), sessionId });
690
+ },
691
+ });
692
+ },
693
+
694
  'settings:get': async (ws, msg, client) => {
695
  const s = client.userId
696
  ? await getUserSettings(client.userId, client.accessToken)
 
704
  bcast(wsClients, client.userId, { type: 'settings:updated', settings: msg.settings }, ws);
705
  },
706
 
707
+ 'personalization:get': async (ws, msg, client) => {
708
+ const defaultPrompt = await systemPromptStore.getDefaultPrompt();
709
+ const personalization = client.userId
710
+ ? await systemPromptStore.getPersonalization(client.userId)
711
+ : {
712
+ defaultPrompt,
713
+ customPrompt: null,
714
+ resolvedPrompt: defaultPrompt,
715
+ isCustom: false,
716
+ updatedAt: null,
717
+ canEdit: false,
718
+ };
719
+ safeSend(ws, { type: 'personalization:data', personalization });
720
+ },
721
+ 'personalization:saveSystemPrompt': async (ws, msg, client, wsClients) => {
722
+ if (!client.userId) return safeSend(ws, { type: 'personalization:error', message: 'Sign in to customize your system prompt' });
723
+ try {
724
+ const personalization = await systemPromptStore.setUserPrompt(client.userId, msg.markdown);
725
+ safeSend(ws, { type: 'personalization:updated', personalization });
726
+ bcast(wsClients, client.userId, { type: 'personalization:updated', personalization }, ws);
727
+ } catch (err) {
728
+ safeSend(ws, { type: 'personalization:error', message: err.message || 'Unable to save system prompt' });
729
+ }
730
+ },
731
+ 'personalization:resetSystemPrompt': async (ws, msg, client, wsClients) => {
732
+ if (!client.userId) return safeSend(ws, { type: 'personalization:error', message: 'Sign in to customize your system prompt' });
733
+ try {
734
+ const personalization = await systemPromptStore.resetUserPrompt(client.userId);
735
+ safeSend(ws, { type: 'personalization:updated', personalization });
736
+ bcast(wsClients, client.userId, { type: 'personalization:updated', personalization }, ws);
737
+ } catch (err) {
738
+ safeSend(ws, { type: 'personalization:error', message: err.message || 'Unable to reset system prompt' });
739
+ }
740
+ },
741
+
742
+ 'memories:list': async (ws, msg, client) => {
743
+ const owner = getClientOwner(client);
744
+ const items = await memoryStore.list(owner);
745
+ safeSend(ws, { type: 'memories:list', items });
746
+ },
747
+
748
+ 'memories:create': async (ws, msg, client, wsClients) => {
749
+ const owner = getClientOwner(client);
750
+ const memory = await memoryStore.create(owner, {
751
+ content: msg.content,
752
+ sessionId: msg.sessionId || null,
753
+ source: msg.source || 'manual',
754
+ });
755
+ safeSend(ws, { type: 'memories:created', memory });
756
+ if (client.userId) bcast(wsClients, client.userId, { type: 'memories:changed' }, ws);
757
+ },
758
+
759
+ 'memories:update': async (ws, msg, client, wsClients) => {
760
+ const owner = getClientOwner(client);
761
+ const memory = await memoryStore.update(owner, msg.id, msg.content);
762
+ safeSend(ws, { type: 'memories:updated', memory });
763
+ if (client.userId) bcast(wsClients, client.userId, { type: 'memories:changed' }, ws);
764
+ },
765
+
766
+ 'memories:delete': async (ws, msg, client, wsClients) => {
767
+ const owner = getClientOwner(client);
768
+ const ok = await memoryStore.delete(owner, msg.id);
769
+ safeSend(ws, { type: 'memories:deleted', id: ok ? msg.id : null });
770
+ if (client.userId) bcast(wsClients, client.userId, { type: 'memories:changed' }, ws);
771
+ },
772
+
773
  'account:getProfile': async (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:profile', profile: await getUserProfile(c.userId, c.accessToken) }); },
774
  'account:setUsername': async (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:usernameResult', ...await setUsername(c.userId, c.accessToken, msg.username) }); },
775
  'account:getSubscription': async (ws, msg, c) => {
 
777
  const subInfo = await getSubscriptionInfo(c.accessToken);
778
  safeSend(ws, { type: 'account:subscription', info: subInfo });
779
  },
780
+ 'account:getUsage': async (ws, msg, c) => { safeSend(ws, { type: 'account:usage', usage: await buildUsagePayload(c, msg.clientId || '') }); },
781
  'account:getTierConfig': async (ws) => { safeSend(ws, { type: 'account:tierConfig', config: await getTierConfig() }); },
782
  'account:getSessions': (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:deviceSessions', sessions: deviceSessionStore.getForUser(c.userId), currentToken: c.deviceToken }); },
783
+ 'account:revokeSession': (ws, msg, c, wsClients) => {
784
+ if (!c.userId || !msg.token) return;
785
+ const revoked = deviceSessionStore.revoke(msg.token);
786
+ if (revoked) {
787
+ const activeSessions = deviceSessionStore.getForUser(c.userId);
788
+ for (const [ows, oc] of wsClients) {
789
+ if (oc.userId !== c.userId) continue;
790
+ if (oc.deviceToken === msg.token) {
791
+ safeSend(ows, { type: 'auth:forcedLogout', reason: 'Session revoked by another device' });
792
+ sessionStore.markOffline(oc.userId, ows);
793
+ Object.assign(oc, { userId: null, authenticated: false, accessToken: null, deviceToken: null });
794
+ continue;
795
+ }
796
+ safeSend(ows, {
797
+ type: 'account:deviceSessions',
798
+ sessions: activeSessions,
799
+ currentToken: oc.deviceToken,
800
+ });
801
+ }
802
+ }
803
+ safeSend(ws, { type: 'account:sessionRevoked', token: msg.token });
804
+ },
805
  'account:revokeAllOthers': (ws, msg, c, wsClients) => {
806
  if (!c.userId) return;
807
  deviceSessionStore.revokeAllExcept(c.userId, c.deviceToken);
808
+ const activeSessions = deviceSessionStore.getForUser(c.userId);
809
+ for (const [ows, oc] of wsClients) {
810
+ if (oc.userId !== c.userId) continue;
811
+ if (oc.deviceToken && oc.deviceToken !== c.deviceToken) {
812
+ safeSend(ows, { type: 'auth:forcedLogout', reason: 'Session revoked by another device' });
813
+ sessionStore.markOffline(oc.userId, ows);
814
+ Object.assign(oc, { userId: null, authenticated: false, accessToken: null, deviceToken: null });
815
+ continue;
816
+ }
817
+ safeSend(ows, {
818
+ type: 'account:deviceSessions',
819
+ sessions: activeSessions,
820
+ currentToken: oc.deviceToken,
821
+ });
822
+ }
823
  safeSend(ws, { type: 'account:allOthersRevoked' });
824
  },
825
  };
826
 
827
  function ser(s) { return { id: s.id, name: s.name, created: s.created, history: s.history || [], model: s.model }; }
828
 
829
+ function getClientOwner(client) {
830
+ return client.userId
831
+ ? { type: 'user', id: client.userId }
832
+ : { type: 'guest', id: client.tempId };
833
+ }
834
+
835
+ async function restoreDeletedSession(client, snapshot) {
836
+ if (!snapshot) return null;
837
+ const restored = JSON.parse(JSON.stringify(snapshot));
838
+ const existing = client.userId
839
+ ? sessionStore.getUserSession(client.userId, restored.id)
840
+ : sessionStore.getTempSession(client.tempId, restored.id);
841
+ if (existing) restored.id = crypto.randomUUID();
842
+ restored.created = restored.created || Date.now();
843
+ if (client.userId) {
844
+ return sessionStore.restoreUserSession(client.userId, client.accessToken, restored);
845
+ }
846
+ return sessionStore.restoreTempSession(client.tempId, restored);
847
+ }
848
+
849
  function generateMessageId() {
850
  return `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
851
  }
852
 
853
+ function buildEntry(role, content, toolCalls = [], extraFields = {}) {
854
  const normalizedCalls = toolCalls.map(c => ({
855
  id: c.id,
856
  name: c.name || c.function?.name,
 
859
  result: c.result,
860
  }));
861
  const validContent = (content === undefined || content === null) ? '' : content;
862
+ const versionMeta = {};
863
+ const topLevelExtraFields = { ...extraFields };
864
+ VERSION_META_FIELDS.forEach((key) => {
865
+ if (key in topLevelExtraFields) {
866
+ versionMeta[key] = topLevelExtraFields[key];
867
+ delete topLevelExtraFields[key];
868
+ }
869
+ });
870
  return {
871
  id: generateMessageId(),
872
  role,
873
  content: validContent,
874
  timestamp: Date.now(),
875
+ versions: [{
876
+ content: validContent,
877
+ tail: [],
878
+ timestamp: Date.now(),
879
+ ...(normalizedCalls.length ? { toolCalls: normalizedCalls } : {}),
880
+ ...versionMeta,
881
+ }],
882
+ currentVersionIdx: 0,
883
+ ...(normalizedCalls.length ? { toolCalls: normalizedCalls } : {}),
884
+ ...versionMeta,
885
+ ...topLevelExtraFields,
886
+ };
887
+ }
888
+
889
+ function buildMediaEntry(role, content) {
890
+ return {
891
+ id: generateMessageId(),
892
+ role,
893
+ content,
894
+ timestamp: Date.now(),
895
+ versions: [{ content, tail: [], timestamp: Date.now() }],
896
  currentVersionIdx: 0,
 
897
  };
898
  }
899
 
900
+ function buildVersionRecord(content, extraFields = {}) {
901
+ const validContent = content === undefined || content === null ? '' : content;
902
+ const version = {
903
+ content: validContent,
904
+ tail: Array.isArray(extraFields.tail) ? extraFields.tail : [],
905
+ timestamp: Date.now(),
906
+ };
907
+ VERSION_META_FIELDS.forEach((key) => {
908
+ if (extraFields[key] !== undefined && extraFields[key] !== null) {
909
+ version[key] = extraFields[key];
910
+ }
911
+ });
912
+ return version;
913
+ }
914
+
915
+ function applyVersionToMessage(message, versionRecord) {
916
+ if (!message?.versions || !Array.isArray(message.versions)) {
917
+ message.versions = [];
918
+ }
919
+ message.versions.push(versionRecord);
920
+ message.currentVersionIdx = message.versions.length - 1;
921
+ syncMessageFromActiveVersion(message);
922
+ return message;
923
+ }
924
+
925
+ function stripSessionTagText(content) {
926
+ return typeof content === 'string'
927
+ ? content.replace(/<session_name>[\s\S]*?<\/session_name>/gi, '').trim()
928
+ : content;
929
+ }
930
+
931
+ function stripContinuationOverlap(baseText = '', generatedText = '') {
932
+ const base = String(baseText || '');
933
+ const generated = String(generatedText || '');
934
+ if (!base) return { continuationText: generated, overlapLength: 0 };
935
+ if (!generated) return { continuationText: '', overlapLength: 0 };
936
+ if (generated.startsWith(base)) {
937
+ return { continuationText: generated.slice(base.length), overlapLength: base.length };
938
+ }
939
+
940
+ const maxWindow = Math.min(base.length, generated.length, 400);
941
+ for (let size = maxWindow; size > 0; size--) {
942
+ if (base.slice(-size) === generated.slice(0, size)) {
943
+ return { continuationText: generated.slice(size), overlapLength: size };
944
+ }
945
+ }
946
+
947
+ return { continuationText: generated, overlapLength: 0 };
948
+ }
949
+
950
+ function trimLeadingTextFromSegments(segments = [], overlapLength = 0) {
951
+ let remaining = Math.max(0, overlapLength);
952
+ const trimmedSegments = [];
953
+
954
+ for (const segment of segments || []) {
955
+ if (!segment || segment.type !== 'text' || remaining <= 0) {
956
+ trimmedSegments.push(segment);
957
+ continue;
958
+ }
959
+
960
+ const text = String(segment.text || '');
961
+ if (remaining >= text.length) {
962
+ remaining -= text.length;
963
+ continue;
964
+ }
965
+
966
+ trimmedSegments.push({
967
+ ...segment,
968
+ text: text.slice(remaining),
969
+ });
970
+ remaining = 0;
971
+ }
972
+
973
+ return trimmedSegments.filter((segment) => segment && (segment.type !== 'text' || segment.text));
974
+ }
975
+
976
+ function extractAssistantGeneratedFiles(text) {
977
+ if (!text || typeof text !== 'string') return [];
978
+ const files = [];
979
+ const detailsRe = /<details><summary>([^<]+?)<\/summary>\s*```(?:\w*)\n([\s\S]*?)\n```\s*<\/details>/g;
980
+ const svgRe = /```svg\n([\s\S]*?)\n```/g;
981
+ let match;
982
+
983
+ while ((match = detailsRe.exec(text)) !== null) {
984
+ const name = String(match[1] || '').trim();
985
+ const ext = path.extname(name).toLowerCase();
986
+ files.push({
987
+ name: name || 'generated-file.txt',
988
+ mimeType: ext === '.html' || ext === '.htm' ? 'text/html' : 'text/plain',
989
+ kind: ext === '.html' || ext === '.htm' ? 'rich_text' : 'text',
990
+ buffer: Buffer.from(match[2], 'utf8'),
991
+ });
992
+ }
993
+
994
+ let svgIndex = 1;
995
+ while ((match = svgRe.exec(text)) !== null) {
996
+ files.push({
997
+ name: `generated-image-${svgIndex++}.svg`,
998
+ mimeType: 'image/svg+xml',
999
+ kind: 'image',
1000
+ buffer: Buffer.from(match[1], 'utf8'),
1001
+ });
1002
+ }
1003
+
1004
+ return files;
1005
+ }
1006
+
1007
  /**
1008
  * Validate and repair tree structure after cloning/modification
1009
  * Ensures all messages and versions have valid content property
 
1034
  return rootMessage;
1035
  }
1036
 
1037
+ function cloneAndRepairTree(rootMessage) {
1038
+ return validateAndRepairTree(JSON.parse(JSON.stringify(rootMessage)));
1039
+ }
1040
+
1041
+ function getActiveVersion(message) {
1042
+ if (!message) return null;
1043
+ const versions = Array.isArray(message.versions) ? message.versions : [];
1044
+ if (!versions.length) {
1045
+ message.versions = [{ content: message.content ?? '', tail: [], timestamp: Date.now() }];
1046
+ message.currentVersionIdx = 0;
1047
+ return message.versions[0];
1048
+ }
1049
+ const currentVersionIdx = Number.isInteger(message.currentVersionIdx)
1050
+ ? Math.max(0, Math.min(message.currentVersionIdx, versions.length - 1))
1051
+ : 0;
1052
+ message.currentVersionIdx = currentVersionIdx;
1053
+ if (!Array.isArray(message.versions[currentVersionIdx].tail)) {
1054
+ message.versions[currentVersionIdx].tail = [];
1055
+ }
1056
+ if (message.versions[currentVersionIdx].content === undefined || message.versions[currentVersionIdx].content === null) {
1057
+ message.versions[currentVersionIdx].content = message.content ?? '';
1058
+ }
1059
+ return message.versions[currentVersionIdx];
1060
+ }
1061
+
1062
+ function cloneVersionMetaValue(value) {
1063
+ if (value === undefined) return undefined;
1064
+ return JSON.parse(JSON.stringify(value));
1065
+ }
1066
+
1067
+ function syncMessageFromActiveVersion(message) {
1068
+ if (!message) return message;
1069
+ const currentVersion = getActiveVersion(message);
1070
+ if (!currentVersion) return message;
1071
+ message.content = currentVersion.content ?? message.content ?? '';
1072
+ VERSION_META_FIELDS.forEach((key) => {
1073
+ if (key in currentVersion) {
1074
+ message[key] = cloneVersionMetaValue(currentVersion[key]);
1075
+ } else {
1076
+ delete message[key];
1077
+ }
1078
+ });
1079
+ return message;
1080
+ }
1081
+
1082
+ function getActiveLeafMessage(rootMessage) {
1083
+ let current = rootMessage;
1084
+ while (current) {
1085
+ const currentVersion = getActiveVersion(current);
1086
+ const tail = Array.isArray(currentVersion?.tail) ? currentVersion.tail : [];
1087
+ if (!tail.length) return current;
1088
+ current = tail[tail.length - 1];
1089
+ }
1090
+ return rootMessage;
1091
+ }
1092
+
1093
+ function appendEntriesToActiveLeaf(rootMessage, entries = []) {
1094
+ if (!rootMessage || !entries.length) return rootMessage;
1095
+ const leaf = getActiveLeafMessage(rootMessage);
1096
+ const currentVersion = getActiveVersion(leaf);
1097
+ currentVersion.tail = [...(currentVersion.tail || []), ...entries];
1098
+ return rootMessage;
1099
+ }
1100
+
1101
+ function appendConversationTurn(rootMessage, userEntry, assistantEntry, mediaEntries = []) {
1102
+ if (!rootMessage || !userEntry) return rootMessage;
1103
+ const leaf = getActiveLeafMessage(rootMessage);
1104
+ const currentVersion = getActiveVersion(leaf);
1105
+ const userVersion = getActiveVersion(userEntry);
1106
+ userVersion.tail = [
1107
+ ...(assistantEntry ? [assistantEntry] : []),
1108
+ ...(Array.isArray(mediaEntries) ? mediaEntries : []),
1109
+ ];
1110
+ syncMessageFromActiveVersion(userEntry);
1111
+ currentVersion.tail = [...(currentVersion.tail || []), userEntry];
1112
+ return rootMessage;
1113
+ }
1114
+
1115
  function extractFlatHistory(rootMessage) {
1116
  if (!rootMessage) return [];
1117
 
 
1120
  if (msg.content === undefined || msg.content === null) {
1121
  msg.content = '';
1122
  }
1123
+ return syncMessageFromActiveVersion(msg);
1124
  };
1125
 
1126
  const history = [ensureValidContent(rootMessage)];
 
1140
 
1141
  if (currentTail && Array.isArray(currentTail)) {
1142
  const walkTail = (tail) => {
1143
+ for (let i = 0; i < tail.length; i++) {
1144
+ const msg = tail[i];
1145
  history.push(ensureValidContent(msg));
1146
  const ver = msg.versions?.[msg.currentVersionIdx ?? 0];
1147
  if (ver?.tail && Array.isArray(ver.tail)) {
1148
  walkTail(ver.tail);
1149
  }
1150
+ if (msg.role === 'user' && Array.isArray(msg.versions) && msg.versions.length > 1) {
1151
+ break;
1152
+ }
1153
  }
1154
  };
1155
  walkTail(currentTail);
 
1157
  return history;
1158
  }
1159
 
1160
+ function findMessageContext(rootMessage, targetId) {
1161
+ if (!rootMessage) return null;
1162
+ if (rootMessage.id === targetId) {
1163
+ return { message: rootMessage, parent: null, parentTail: null, index: -1 };
1164
+ }
1165
+
1166
+ const search = (msg) => {
1167
+ const verIdx = msg.currentVersionIdx ?? 0;
1168
+ const tail = msg.versions?.[verIdx]?.tail;
1169
+ if (!tail || !Array.isArray(tail)) return null;
1170
+
1171
+ for (let i = 0; i < tail.length; i++) {
1172
+ const child = tail[i];
1173
+ if (child.id === targetId) {
1174
+ return { message: child, parent: msg, parentTail: tail, index: i };
1175
+ }
1176
+ const nested = search(child);
1177
+ if (nested) return nested;
1178
+ }
1179
+ return null;
1180
+ };
1181
+
1182
+ return search(rootMessage);
1183
+ }
1184
+
1185
  function findAndUpdateMessage(rootMessage, targetId, updateFn) {
1186
  if (rootMessage.id === targetId) {
1187
  updateFn(rootMessage);
system prompt.md ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Response formatting
2
+
3
+ - Every response must use HTML `<span data-color="{COLOR NAME}">...</span>` tags to color main points and headings unless the user asks otherwise.
4
+ - Colors must have meaning and stay consistent across the conversation.
5
+ - Only use these semantic color names: `green`, `pink`, `blue`, `red`, `orange`, `yellow`, `purple`, `teal`, `gold`, `coral`.
6
+ - Never output explicit black or white colors.
7
+ - Put color spans as close to the text as possible, and do not place markdown syntax inside the span tags.
8
+ - Keep code blocks plain, but color important surrounding headings and key points.
9
+ - Do not over-color responses. Use color intentionally and sparingly.
10
+ - Markdown markers such as `#`, `##`, `###`, `**`, and `*` must stay outside the color spans.
11
+
12
+ # Core behavior
13
+
14
+ - You are a helpful, friendly AI assistant.
15
+ - Use tools when appropriate to help the user, and if you are told to generate something, use a tool to complete the task.
16
+ - When generating media, do not include URLs because the media is displayed automatically.
17
+ - You can render SVG images by outputting SVG code in a code block tagged exactly like this:
18
+
19
+ ```svg
20
+ <svg>...</svg>
21
+ ```
22
+
23
+ - Never use single backslashes.
24
+ - Use markdown for everything other than the color spans.
25
+ - Tables, lists, and other markdown elements are encouraged when they help.
26
+
27
+ # Tool guide
28
+
29
+ - Use `list_app_doc_sections` to see which documentation sections are available for the app UI, settings, privacy policy, and terms of service.
30
+ - Use `read_app_doc_section` to read only the specific documentation section that answers the user's question.
31
+ - Use `list_memories` at the start of a chat to check persistent memories for the user.
32
+ - Use `save_memory` only for short, durable information that will still matter in future chats.
33
+ - Use `delete_memory` when a saved memory is wrong, outdated, or the user asks to remove it.
34
+ - Use `edit_response_draft` to fix, replace, append to, or remove text that has already been streamed to the user. Prefer targeted edits when possible.
35
+ - Use `ollama_search` for current information, recent facts, news, or anything you are not sure about.
36
+ - Use `read_web_page` to inspect a specific URL after search or when the user gives you a link.
37
+ - Use `generate_image` when the user wants a new image or image variation.
38
+ - Use `generate_video` when the user wants a generated video, animation, or moving scene.
39
+ - Use `generate_audio` when the user wants music, ambience, or sound effects.
40
+ - Use `list_prompt_resources` to discover staged prompt resources such as large text attachments and separated prompt chunks.
41
+ - Use `read_prompt_chunk` to read staged text exactly instead of guessing about omitted or truncated content.
42
+ - Use `load_prompt_images` to inspect staged images that were attached outside the inline prompt.
43
+ - Use `write_notes` to keep short working notes after reading several prompt chunks or attachments.
44
+
45
+ # Attachment handling
46
+
47
+ - Large user prompts, text attachments, conversation history, and image attachments may be staged into separate resources on purpose.
48
+ - If notes say attached text was staged separately, or that only the first part of a prompt is inline, do not assume the content is missing, corrupted, or truncated.
49
+ - Treat staged content as available context.
50
+ - Use `list_prompt_resources` to find staged resources.
51
+ - Use `read_prompt_chunk` to read staged text exactly.
52
+ - Use `load_prompt_images` to inspect staged images.
53
+ - Use `write_notes` to keep a compact working memory after reading several chunks.
54
+ - Before claiming an attachment is incomplete, missing, malformed, or unreadable, first check whether it was staged separately and read the relevant resource.
55
+
56
+ # Memory
57
+
58
+ - Persistent memories must stay short, concrete, and durable.
59
+ - Only save memories that will still help in future chats.
60
+ - Keep each memory to a brief sentence or phrase.
61
+ - At the start of a chat, always check the memories.
62
+ - If the user tells you to remember something, or there is something important to note for future chats, create a new memory.
63
+ - Memories should be brief.
64
+ - Notes are only for session-long memory, so use memories for anything relevant to future chats.
65
+
66
+ # Priorities
67
+
68
+ - Your highest priority is to help the user.
69
+ - Always help with anything ethically right.
70
+ - Make sure your responses are always accurate.
71
+ - If you are not completely sure about something, search the web.
72
+ - If you notice any issue or mistake with your response, correct it with the replace tools.
73
+ - Always answer as correctly as possible, and use search when unsure.
74
+ - Try to minimize the use of `*` for emphasis. Use it mainly for markdown structure.
75
+
76
+ # Session naming
77
+
78
+ - After you have fully responded to the user, append a session name tag on its own line at the very end of your response, never inside a code block.
79
+ - Only do this on the first response unless the user asks to change the name.
80
+ - The tag must be `<session_name>2-4 word title summarizing this conversation</session_name>`.
81
+ - Example: `<session_name>React State Management</session_name>`.
82
+ - A conversation must always be named on the first response.
83
+ - This tag is hidden from the user and is used only to name the chat.
84
+ - Do not mention the tag to the user.
85
+ - Do not name your session "New Chat". Choose a descriptive name.