fluxy-bot 0.1.46 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,234 +1,108 @@
1
1
  import http from 'http';
2
2
  import net from 'net';
3
+ import fs from 'fs';
3
4
  import { WebSocketServer, WebSocket } from 'ws';
4
5
  import { loadConfig, saveConfig } from '../shared/config.js';
5
6
  import { createProvider, type AiProvider, type ChatMessage } from '../shared/ai.js';
6
- import { paths, PKG_DIR } from '../shared/paths.js';
7
+ import { paths } from '../shared/paths.js';
7
8
  import { log } from '../shared/logger.js';
8
- import { initDb, closeDb, createConversation, addMessage, getMessages, getSetting } from '../shared/db.js';
9
- import { initWorkspace } from '../shared/workspace.js';
10
- import { ensureFileDirs, saveFile } from '../shared/file-storage.js';
11
- import { startAgentQuery, stopAgentQuery } from './claude-agent.js';
12
9
  import { startTunnel, stopTunnel, isTunnelAlive, restartTunnel } from './tunnel.js';
13
10
  import { spawnWorker, stopWorker, getWorkerPort, isWorkerAlive } from './worker.js';
14
- import { spawnVite, stopVite, getVitePort, isViteAlive } from './vite-dev.js';
15
11
  import { updateTunnelUrl, startHeartbeat, stopHeartbeat, disconnect } from '../shared/relay.js';
16
12
 
17
13
  const RECOVERING_HTML = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Recovering</title>
18
14
  <style>body{background:#0a0a0f;color:#94a3b8;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
19
15
  div{text-align:center}h1{font-size:18px;margin-bottom:8px;color:#e2e8f0}p{font-size:14px}a{color:#60a5fa}</style></head>
20
- <body><div><h1>Dashboard is restarting...</h1><p>Refreshing automatically.</p></div>
16
+ <body><div><h1>Dashboard is restarting...</h1><p>Refreshing automatically. <a href="/fluxy">Talk to Fluxy</a></p></div>
21
17
  <script>setTimeout(()=>location.reload(),3000)</script></body></html>`;
22
18
 
23
- /** Broadcast a message to all connected chat clients */
24
- function broadcastWs(chatWss: WebSocketServer, type: string, data?: any): void {
25
- const payload = JSON.stringify({ type, data });
26
- for (const client of chatWss.clients) {
27
- if (client.readyState === WebSocket.OPEN) client.send(payload);
28
- }
29
- }
30
-
31
- /** Notify clients after every agent turn — HMR handles the actual update */
32
- function runPostIteration(chatWss: WebSocketServer): void {
33
- log.info('[post-iteration] Changes applied — HMR active');
34
- broadcastWs(chatWss, 'changes:applied', { timestamp: Date.now() });
35
- }
36
-
37
19
  export async function startSupervisor() {
38
20
  const config = loadConfig();
39
21
  const workerPort = getWorkerPort(config.port);
40
22
 
41
- // Initialize shared infrastructure in the supervisor (the kernel)
42
- initDb();
43
- initWorkspace();
44
- ensureFileDirs();
45
-
46
- // Active non-Anthropic streams (keyed by conversationId)
47
- const activeStreams = new Map<string, AbortController>();
23
+ // Fluxy's AI brain
24
+ let ai: AiProvider | null = null;
25
+ if (config.ai.provider && (config.ai.apiKey || config.ai.provider === 'ollama')) {
26
+ ai = createProvider(config.ai.provider, config.ai.apiKey, config.ai.baseUrl);
27
+ }
48
28
 
49
- const vitePort = getVitePort(config.port);
29
+ // Fluxy chat conversations (in-memory for now)
30
+ const conversations = new Map<WebSocket, ChatMessage[]>();
50
31
 
51
- // HTTP server — split routing: /api → worker, everything else → Vite
32
+ // HTTP server
52
33
  const server = http.createServer((req, res) => {
53
- const isApiRoute = req.url?.startsWith('/api/') || req.url === '/api';
54
-
55
- if (isApiRoute) {
56
- // API requests → worker
57
- if (!isWorkerAlive()) {
58
- res.writeHead(503, { 'Content-Type': 'text/html' });
59
- res.end(RECOVERING_HTML);
60
- return;
61
- }
62
-
63
- const proxy = http.request(
64
- { host: '127.0.0.1', port: workerPort, path: req.url, method: req.method, headers: req.headers },
65
- (proxyRes) => {
66
- res.writeHead(proxyRes.statusCode!, proxyRes.headers);
67
- proxyRes.pipe(res);
68
- },
69
- );
70
-
71
- proxy.on('error', () => {
72
- res.writeHead(503, { 'Content-Type': 'text/html' });
73
- res.end(RECOVERING_HTML);
74
- });
75
-
76
- req.pipe(proxy);
77
- } else {
78
- // Frontend requests → Vite dev server (fallback to worker if Vite is down)
79
- const targetPort = isViteAlive() ? vitePort : workerPort;
80
-
81
- // Override Host header to localhost so Vite accepts the request
82
- const headers = { ...req.headers, host: `127.0.0.1:${targetPort}` };
34
+ // Fluxy routes served directly, never proxied
35
+ if (req.url === '/fluxy' || req.url === '/fluxy/') {
36
+ res.writeHead(200, { 'Content-Type': 'text/html' });
37
+ res.end(fs.readFileSync(paths.fluxyHtml));
38
+ return;
39
+ }
83
40
 
84
- const proxy = http.request(
85
- { host: '127.0.0.1', port: targetPort, path: req.url, method: req.method, headers },
86
- (proxyRes) => {
87
- res.writeHead(proxyRes.statusCode!, proxyRes.headers);
88
- proxyRes.pipe(res);
89
- },
90
- );
41
+ // Everything else → proxy to worker
42
+ if (!isWorkerAlive()) {
43
+ res.writeHead(503, { 'Content-Type': 'text/html' });
44
+ res.end(RECOVERING_HTML);
45
+ return;
46
+ }
91
47
 
92
- proxy.on('error', () => {
93
- res.writeHead(503, { 'Content-Type': 'text/html' });
94
- res.end(RECOVERING_HTML);
95
- });
48
+ const proxy = http.request(
49
+ { host: '127.0.0.1', port: workerPort, path: req.url, method: req.method, headers: req.headers },
50
+ (proxyRes) => {
51
+ res.writeHead(proxyRes.statusCode!, proxyRes.headers);
52
+ proxyRes.pipe(res);
53
+ },
54
+ );
55
+
56
+ proxy.on('error', () => {
57
+ res.writeHead(503, { 'Content-Type': 'text/html' });
58
+ res.end(RECOVERING_HTML);
59
+ });
96
60
 
97
- req.pipe(proxy);
98
- }
61
+ req.pipe(proxy);
99
62
  });
100
63
 
101
- // WebSocket: chat handled directly by supervisor
102
- const chatWss = new WebSocketServer({ noServer: true });
64
+ // WebSocket: Fluxy chat + proxy worker WS
65
+ const fluxyWss = new WebSocketServer({ noServer: true });
103
66
 
104
- chatWss.on('connection', (ws: WebSocket) => {
105
- log.info('Chat client connected to supervisor');
67
+ fluxyWss.on('connection', (ws) => {
68
+ log.info('Fluxy chat connected');
69
+ conversations.set(ws, []);
106
70
 
107
71
  ws.on('message', (raw) => {
108
- const text = raw.toString();
109
-
110
- // Heartbeat ping/pong
111
- if (text === 'ping') { ws.send('pong'); return; }
112
-
113
- const msg = JSON.parse(text);
114
-
115
- if (msg.type === 'user:message') {
116
- // Re-read config on each message so onboarding changes are picked up
117
- const freshConfig = loadConfig();
118
-
119
- const { conversationId, content, attachments, audioData } = msg.data;
120
- let convId = conversationId;
121
- if (!convId) convId = createConversation(content.slice(0, 50), freshConfig.ai.model).id;
122
-
123
- // Save audio file to disk
124
- let audioPath: string | undefined;
125
- if (audioData) {
126
- try {
127
- audioPath = saveFile(audioData, 'audio', 'webm');
128
- } catch (err: any) {
129
- log.warn(`Failed to save audio file: ${err.message}`);
130
- audioPath = audioData; // fallback to inline
131
- }
132
- }
72
+ const msg = JSON.parse(raw.toString());
73
+ if (msg.type !== 'message' || !msg.content) return;
133
74
 
134
- // Save attachments to disk
135
- type StoredAttachment = { type: string; name: string; mediaType: string; filePath: string };
136
- let storedAttachments: StoredAttachment[] | undefined;
137
- if (attachments?.length) {
138
- storedAttachments = [];
139
- for (const att of attachments) {
140
- try {
141
- const isImage = att.mediaType?.startsWith('image/');
142
- const ext = att.name?.split('.').pop() || (isImage ? 'jpg' : 'bin');
143
- const category = isImage ? 'images' as const : 'documents' as const;
144
- const filePath = saveFile(att.data, category, ext);
145
- storedAttachments.push({ type: att.type, name: att.name, mediaType: att.mediaType, filePath });
146
- } catch (err: any) {
147
- log.warn(`Failed to save attachment ${att.name}: ${err.message}`);
148
- }
149
- }
150
- }
75
+ const history = conversations.get(ws) || [];
76
+ history.push({ role: 'user', content: msg.content });
151
77
 
152
- const msgMeta: any = {};
153
- if (audioPath) msgMeta.audio_data = audioPath;
154
- if (storedAttachments?.length) msgMeta.attachments = JSON.stringify(storedAttachments);
155
- addMessage(convId, 'user', content, Object.keys(msgMeta).length > 0 ? msgMeta : undefined);
156
-
157
- // Route Anthropic provider through Agent SDK
158
- if (freshConfig.ai.provider === 'anthropic') {
159
- let usedTools = false;
160
- startAgentQuery(convId, content, freshConfig.ai.model, (type, data) => {
161
- if (type === 'bot:tool') usedTools = true;
162
- if (type === 'bot:response') {
163
- addMessage(convId, 'assistant', data.content, { model: freshConfig.ai.model });
164
- }
165
- ws.send(JSON.stringify({ type, data: { ...data, conversationId: convId } }));
166
- }, attachments).then(() => {
167
- if (usedTools) runPostIteration(chatWss);
168
- });
169
- return;
170
- }
171
-
172
- // Non-Anthropic providers use ai.chat()
173
- let ai: AiProvider | null = null;
174
- if (freshConfig.ai.provider && (freshConfig.ai.apiKey || freshConfig.ai.provider === 'ollama')) {
175
- ai = createProvider(freshConfig.ai.provider, freshConfig.ai.apiKey, freshConfig.ai.baseUrl);
176
- }
177
-
178
- if (!ai) {
179
- ws.send(JSON.stringify({ type: 'bot:error', data: { conversationId: convId, error: 'AI not configured' } }));
180
- return;
181
- }
182
-
183
- const history = getMessages(convId) as { role: string; content: string }[];
184
- const systemPrompt = getSetting('system_prompt');
185
- const messages: ChatMessage[] = [
186
- ...(systemPrompt ? [{ role: 'system' as const, content: systemPrompt }] : []),
187
- ...history.map((m) => ({ role: m.role as ChatMessage['role'], content: m.content })),
188
- ];
189
-
190
- ws.send(JSON.stringify({ type: 'bot:typing', data: { conversationId: convId } }));
191
- const ctrl = new AbortController();
192
- activeStreams.set(convId, ctrl);
193
-
194
- ai.chat(
195
- messages,
196
- freshConfig.ai.model,
197
- (token) => ws.send(JSON.stringify({ type: 'bot:token', data: { conversationId: convId, token } })),
198
- (full, usage) => {
199
- const m = addMessage(convId, 'assistant', full, { tokens_in: usage?.tokensIn, tokens_out: usage?.tokensOut, model: freshConfig.ai.model });
200
- ws.send(JSON.stringify({ type: 'bot:response', data: { conversationId: convId, messageId: (m as any).id, content: full } }));
201
- activeStreams.delete(convId);
202
- },
203
- (err) => {
204
- ws.send(JSON.stringify({ type: 'bot:error', data: { conversationId: convId, error: err.message } }));
205
- activeStreams.delete(convId);
206
- },
207
- ctrl.signal,
208
- );
78
+ if (!ai) {
79
+ ws.send(JSON.stringify({ type: 'error', error: 'AI not configured. Set up your provider first.' }));
80
+ return;
209
81
  }
210
82
 
211
- if (msg.type === 'user:stop') {
212
- // Stop Agent SDK queries
213
- stopAgentQuery(msg.data.conversationId);
214
- // Stop legacy streams
215
- const ctrl = activeStreams.get(msg.data.conversationId);
216
- if (ctrl) { ctrl.abort(); activeStreams.delete(msg.data.conversationId); }
217
- }
83
+ ai.chat(
84
+ [{ role: 'system', content: 'You are Fluxy, a helpful AI assistant. You help users manage and customize their self-hosted bot.' }, ...history],
85
+ config.ai.model,
86
+ (token) => ws.send(JSON.stringify({ type: 'token', token })),
87
+ (full) => {
88
+ history.push({ role: 'assistant', content: full });
89
+ ws.send(JSON.stringify({ type: 'done' }));
90
+ },
91
+ (err) => ws.send(JSON.stringify({ type: 'error', error: err.message })),
92
+ );
218
93
  });
94
+
95
+ ws.on('close', () => conversations.delete(ws));
219
96
  });
220
97
 
221
98
  server.on('upgrade', (req, socket: net.Socket, head) => {
222
- if (req.url === '/ws') {
223
- chatWss.handleUpgrade(req, socket, head, (ws) => chatWss.emit('connection', ws, req));
99
+ if (req.url === '/fluxy/ws') {
100
+ fluxyWss.handleUpgrade(req, socket, head, (ws) => fluxyWss.emit('connection', ws, req));
224
101
  return;
225
102
  }
226
103
 
227
- // Proxy all other WS upgrades to Vite (HMR), fallback to worker
228
- const targetPort = isViteAlive() ? vitePort : (isWorkerAlive() ? workerPort : 0);
229
- if (!targetPort) { socket.destroy(); return; }
230
-
231
- const proxy = net.connect(targetPort, () => {
104
+ // Proxy WS upgrade to worker
105
+ const proxy = net.connect(workerPort, () => {
232
106
  const headers = Object.entries(req.headers).map(([k, v]) => `${k}: ${v}`).join('\r\n');
233
107
  proxy.write(`GET ${req.url} HTTP/1.1\r\n${headers}\r\n\r\n`);
234
108
  if (head.length > 0) proxy.write(head);
@@ -251,11 +125,11 @@ export async function startSupervisor() {
251
125
 
252
126
  server.listen(config.port, () => {
253
127
  log.ok(`Supervisor on http://localhost:${config.port}`);
128
+ log.ok(`Fluxy chat at http://localhost:${config.port}/fluxy`);
254
129
  });
255
130
 
256
- // Spawn worker and Vite dev server
131
+ // Spawn worker
257
132
  spawnWorker(workerPort);
258
- await spawnVite(vitePort);
259
133
 
260
134
  // Tunnel
261
135
  let tunnelUrl: string | null = null;
@@ -335,9 +209,7 @@ export async function startSupervisor() {
335
209
  delete latestConfig.tunnelUrl;
336
210
  saveConfig(latestConfig);
337
211
  stopWorker();
338
- stopVite();
339
212
  stopTunnel();
340
- closeDb();
341
213
  server.close();
342
214
  process.exit(0);
343
215
  };
@@ -1,6 +1,6 @@
1
1
  import { spawn, type ChildProcess } from 'child_process';
2
2
  import path from 'path';
3
- import { PKG_DIR, paths } from '../shared/paths.js';
3
+ import { PKG_DIR } from '../shared/paths.js';
4
4
  import { log } from '../shared/logger.js';
5
5
 
6
6
  let child: ChildProcess | null = null;
@@ -14,20 +14,7 @@ export function getWorkerPort(basePort: number): number {
14
14
  export function spawnWorker(port: number): ChildProcess {
15
15
  const workerPath = path.join(PKG_DIR, 'worker', 'index.ts');
16
16
 
17
- // --watch: Node built-in file watcher (stable since Node 22).
18
- // Keeps the parent process alive and internally restarts
19
- // the worker when imported files change.
20
- // --watch-path: scope watching to worker/ and shared/ so frontend
21
- // edits don't cause unnecessary restarts.
22
- child = spawn(process.execPath, [
23
- '--watch',
24
- '--watch-preserve-output',
25
- `--watch-path=${path.join(PKG_DIR, 'worker')}`,
26
- `--watch-path=${path.join(PKG_DIR, 'shared')}`,
27
- `--watch-path=${paths.env}`,
28
- '--import', 'tsx/esm',
29
- workerPath,
30
- ], {
17
+ child = spawn(process.execPath, ['--import', 'tsx/esm', workerPath], {
31
18
  cwd: PKG_DIR,
32
19
  stdio: ['ignore', 'pipe', 'pipe'],
33
20
  env: { ...process.env, WORKER_PORT: String(port) },
@@ -54,7 +41,7 @@ export function spawnWorker(port: number): ChildProcess {
54
41
  }
55
42
  });
56
43
 
57
- log.ok(`Worker spawned on port ${port} (watch mode)`);
44
+ log.ok(`Worker spawned on port ${port}`);
58
45
  return child;
59
46
  }
60
47
 
package/tsconfig.json CHANGED
@@ -9,11 +9,10 @@
9
9
  "outDir": "dist",
10
10
  "rootDir": ".",
11
11
  "jsx": "react-jsx",
12
- "types": ["vite/client"],
12
+ "types": [],
13
13
  "paths": {
14
14
  "@server/*": ["./server/*"],
15
- "@client/*": ["./client/src/*"],
16
- "@/*": ["./client/src/*"]
15
+ "@client/*": ["./client/src/*"]
17
16
  }
18
17
  },
19
18
  "include": ["server/**/*", "client/src/**/*", "vite.config.ts"],
package/vite.config.ts CHANGED
@@ -13,7 +13,10 @@ export default defineConfig({
13
13
  emptyOutDir: true,
14
14
  },
15
15
  server: {
16
- allowedHosts: true,
16
+ port: 5173,
17
+ proxy: {
18
+ '/api': 'http://localhost:3000',
19
+ },
17
20
  },
18
21
  plugins: [
19
22
  react(),
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Claude Agent SDK service — runs Claude Code as a subprocess with tool use
3
- * and the claude_code system prompt preset.
2
+ * Claude Agent SDK service — runs Claude Code as a subprocess with tool use,
3
+ * session persistence, and the claude_code system prompt preset.
4
4
  *
5
- * Uses workspace files + message transcript instead of session resume.
5
+ * Replaces the raw Anthropic Messages API for Anthropic-provider chats.
6
6
  */
7
7
 
8
8
  import { query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
@@ -10,10 +10,10 @@ import fs from 'fs';
10
10
  import path from 'path';
11
11
  import os from 'os';
12
12
  import { log } from '../shared/logger.js';
13
- import { getLastMessages } from '../shared/db.js';
14
- import { buildSystemPromptAddendum, initWorkspace } from '../shared/workspace.js';
13
+ import { getSessionId, saveSessionId } from './db.js';
15
14
 
16
15
  const CREDENTIALS_FILE = path.join(os.homedir(), '.claude', '.credentials.json');
16
+ const PROMPT_FILE = path.join(import.meta.dirname, 'prompts', 'fluxy-system-prompt.txt');
17
17
 
18
18
  export interface AgentAttachment {
19
19
  type: 'image' | 'file';
@@ -24,19 +24,11 @@ export interface AgentAttachment {
24
24
 
25
25
  interface ActiveSession {
26
26
  abortController: AbortController;
27
+ sessionId?: string;
27
28
  }
28
29
 
29
30
  const activeSessions = new Map<string, ActiveSession>();
30
31
 
31
- /** Read the pointer prompt from the bundled file */
32
- function readPointerPrompt(): string {
33
- try {
34
- return fs.readFileSync(path.join(import.meta.dirname, 'prompts', 'fluxy-system-prompt.txt'), 'utf-8').trim();
35
- } catch {
36
- return '';
37
- }
38
- }
39
-
40
32
  /** Read the OAuth token stored by claude-auth.ts */
41
33
  function readOAuthToken(): string | null {
42
34
  try {
@@ -51,24 +43,13 @@ function readOAuthToken(): string | null {
51
43
  return null;
52
44
  }
53
45
 
54
- /** Build a conversation transcript from the last N messages + current message */
55
- function buildTranscript(conversationId: string, currentMessage: string): string {
56
- const rows = getLastMessages(conversationId, 20) as { role: string; content: string }[];
57
- const lines: string[] = [];
58
-
59
- if (rows.length > 0) {
60
- lines.push('[Recent conversation history]');
61
- for (const row of rows) {
62
- const tag = row.role === 'user' ? 'USER' : 'ASSISTANT';
63
- lines.push(`${tag}: ${row.content}`);
64
- }
65
- lines.push('');
46
+ /** Read the custom system prompt addendum */
47
+ function readSystemPromptAddendum(): string {
48
+ try {
49
+ return fs.readFileSync(PROMPT_FILE, 'utf-8').trim();
50
+ } catch {
51
+ return '';
66
52
  }
67
-
68
- lines.push('[Current message]');
69
- lines.push(`USER: ${currentMessage}`);
70
-
71
- return lines.join('\n');
72
53
  }
73
54
 
74
55
  /** Build a multi-part prompt with attachments for the SDK */
@@ -83,6 +64,7 @@ function buildMultiPartPrompt(text: string, attachments: AgentAttachment[]): Asy
83
64
  source: { type: 'base64', media_type: att.mediaType, data: att.data },
84
65
  });
85
66
  } else {
67
+ // PDF / document
86
68
  content.push({
87
69
  type: 'document',
88
70
  source: { type: 'base64', media_type: att.mediaType, data: att.data },
@@ -103,8 +85,12 @@ function buildMultiPartPrompt(text: string, attachments: AgentAttachment[]): Asy
103
85
  /**
104
86
  * Start an Agent SDK query for a conversation.
105
87
  *
106
- * Each call creates a fresh SDK context with workspace files in the system
107
- * prompt and the last 20 messages as a transcript. No session resume.
88
+ * Streams events back via the onMessage callback:
89
+ * bot:typing — agent started thinking
90
+ * bot:token — text chunk from assistant
91
+ * bot:tool — tool invocation (name + input)
92
+ * bot:response — final complete response
93
+ * bot:error — error
108
94
  */
109
95
  export async function startAgentQuery(
110
96
  conversationId: string,
@@ -120,37 +106,33 @@ export async function startAgentQuery(
120
106
  }
121
107
 
122
108
  const abortController = new AbortController();
123
- activeSessions.set(conversationId, { abortController });
109
+ const existingSessionId = getSessionId(conversationId);
110
+ const addendum = readSystemPromptAddendum();
124
111
 
125
- // Ensure workspace exists (idempotent)
126
- initWorkspace();
127
-
128
- const pointerPrompt = readPointerPrompt();
129
- const addendum = buildSystemPromptAddendum(pointerPrompt);
130
- const transcript = buildTranscript(conversationId, prompt);
112
+ activeSessions.set(conversationId, { abortController, sessionId: existingSessionId ?? undefined });
131
113
 
132
114
  let fullText = '';
133
115
 
134
116
  // Use multi-part prompt when attachments are present
135
117
  const sdkPrompt: string | AsyncIterable<SDKUserMessage> =
136
118
  attachments?.length
137
- ? buildMultiPartPrompt(transcript, attachments)
138
- : transcript;
119
+ ? buildMultiPartPrompt(prompt, attachments)
120
+ : prompt;
139
121
 
140
122
  try {
141
123
  const claudeQuery = query({
142
124
  prompt: sdkPrompt,
143
125
  options: {
144
126
  model,
145
- cwd: os.homedir(),
127
+ cwd: process.cwd(),
146
128
  permissionMode: 'bypassPermissions',
147
129
  allowDangerouslySkipPermissions: true,
148
130
  maxTurns: 50,
149
131
  abortController,
150
- persistSession: false,
151
132
  systemPrompt: addendum
152
133
  ? { type: 'preset', preset: 'claude_code', append: addendum }
153
134
  : { type: 'preset', preset: 'claude_code' },
135
+ ...(existingSessionId ? { resume: existingSessionId } : {}),
154
136
  env: {
155
137
  ...process.env as Record<string, string>,
156
138
  CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
@@ -166,6 +148,7 @@ export async function startAgentQuery(
166
148
  handleSDKMessage(msg, conversationId, fullText, onMessage, (text) => { fullText = text; });
167
149
  }
168
150
 
151
+ // If we accumulated text but didn't hit a result message, send what we have
169
152
  if (fullText && !abortController.signal.aborted) {
170
153
  onMessage('bot:response', { conversationId, content: fullText });
171
154
  }
@@ -189,17 +172,20 @@ function handleSDKMessage(
189
172
  ): void {
190
173
  switch (msg.type) {
191
174
  case 'system':
175
+ // system init — typing already sent
192
176
  break;
193
177
 
194
178
  case 'assistant': {
195
179
  const assistantMsg = msg.message;
196
180
  if (!assistantMsg?.content) break;
197
181
 
198
- // If there's accumulated text from a previous turn, flush it as a separate message
199
- if (currentText) {
200
- onMessage('bot:response', { conversationId, content: currentText });
201
- setText('');
202
- currentText = '';
182
+ // Save session_id from first assistant message
183
+ if (msg.session_id) {
184
+ const session = activeSessions.get(conversationId);
185
+ if (session && !session.sessionId) {
186
+ session.sessionId = msg.session_id;
187
+ saveSessionId(conversationId, msg.session_id);
188
+ }
203
189
  }
204
190
 
205
191
  for (const block of assistantMsg.content) {
@@ -218,9 +204,16 @@ function handleSDKMessage(
218
204
  }
219
205
 
220
206
  case 'result': {
207
+ // Final result — send the accumulated text as the complete response
208
+ if (msg.subtype === 'success') {
209
+ if (msg.session_id) {
210
+ saveSessionId(conversationId, msg.session_id);
211
+ }
212
+ }
213
+
221
214
  if (currentText) {
222
215
  onMessage('bot:response', { conversationId, content: currentText });
223
- setText('');
216
+ setText(''); // prevent duplicate in the finally block
224
217
  } else if (msg.subtype?.startsWith('error')) {
225
218
  const errorText = (msg as any).errors?.join('; ') || 'Agent query failed';
226
219
  onMessage('bot:error', { conversationId, error: errorText });
@@ -1,6 +1,6 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import fs from 'fs';
3
- import { paths, DATA_DIR } from './paths.js';
3
+ import { paths, DATA_DIR } from '../shared/paths.js';
4
4
 
5
5
  const SCHEMA = `
6
6
  CREATE TABLE IF NOT EXISTS conversations (
@@ -35,7 +35,6 @@ export function initDb(): void {
35
35
  fs.mkdirSync(DATA_DIR, { recursive: true });
36
36
  db = new Database(paths.db);
37
37
  db.pragma('journal_mode = WAL');
38
- db.pragma('busy_timeout = 5000');
39
38
  db.pragma('foreign_keys = ON');
40
39
  db.exec(SCHEMA);
41
40
 
@@ -81,13 +80,6 @@ export function addMessage(convId: string, role: string, content: string, meta?:
81
80
  export function getMessages(convId: string) {
82
81
  return db.prepare('SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at ASC').all(convId);
83
82
  }
84
- export function getLastMessages(convId: string, limit = 20) {
85
- return db.prepare(`
86
- SELECT * FROM (
87
- SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at DESC LIMIT ?
88
- ) sub ORDER BY created_at ASC
89
- `).all(convId, limit);
90
- }
91
83
 
92
84
  // Settings
93
85
  export function getSetting(key: string): string | undefined {
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import crypto from 'crypto';
3
- import { paths } from './paths.js';
3
+ import { paths } from '../shared/paths.js';
4
4
 
5
5
  export function ensureFileDirs(): void {
6
6
  fs.mkdirSync(paths.filesAudio, { recursive: true });