fluxy-bot 0.1.45 → 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,231 +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
- });
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
+ }
75
40
 
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
- const proxy = http.request(
82
- { host: '127.0.0.1', port: targetPort, path: req.url, method: req.method, headers: req.headers },
83
- (proxyRes) => {
84
- res.writeHead(proxyRes.statusCode!, proxyRes.headers);
85
- proxyRes.pipe(res);
86
- },
87
- );
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
+ }
88
47
 
89
- proxy.on('error', () => {
90
- res.writeHead(503, { 'Content-Type': 'text/html' });
91
- res.end(RECOVERING_HTML);
92
- });
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
+ });
93
60
 
94
- req.pipe(proxy);
95
- }
61
+ req.pipe(proxy);
96
62
  });
97
63
 
98
- // WebSocket: chat handled directly by supervisor
99
- const chatWss = new WebSocketServer({ noServer: true });
64
+ // WebSocket: Fluxy chat + proxy worker WS
65
+ const fluxyWss = new WebSocketServer({ noServer: true });
100
66
 
101
- chatWss.on('connection', (ws: WebSocket) => {
102
- log.info('Chat client connected to supervisor');
67
+ fluxyWss.on('connection', (ws) => {
68
+ log.info('Fluxy chat connected');
69
+ conversations.set(ws, []);
103
70
 
104
71
  ws.on('message', (raw) => {
105
- const text = raw.toString();
72
+ const msg = JSON.parse(raw.toString());
73
+ if (msg.type !== 'message' || !msg.content) return;
106
74
 
107
- // Heartbeat ping/pong
108
- if (text === 'ping') { ws.send('pong'); return; }
75
+ const history = conversations.get(ws) || [];
76
+ history.push({ role: 'user', content: msg.content });
109
77
 
110
- const msg = JSON.parse(text);
111
-
112
- if (msg.type === 'user:message') {
113
- // Re-read config on each message so onboarding changes are picked up
114
- const freshConfig = loadConfig();
115
-
116
- const { conversationId, content, attachments, audioData } = msg.data;
117
- let convId = conversationId;
118
- if (!convId) convId = createConversation(content.slice(0, 50), freshConfig.ai.model).id;
119
-
120
- // Save audio file to disk
121
- let audioPath: string | undefined;
122
- if (audioData) {
123
- try {
124
- audioPath = saveFile(audioData, 'audio', 'webm');
125
- } catch (err: any) {
126
- log.warn(`Failed to save audio file: ${err.message}`);
127
- audioPath = audioData; // fallback to inline
128
- }
129
- }
130
-
131
- // Save attachments to disk
132
- type StoredAttachment = { type: string; name: string; mediaType: string; filePath: string };
133
- let storedAttachments: StoredAttachment[] | undefined;
134
- if (attachments?.length) {
135
- storedAttachments = [];
136
- for (const att of attachments) {
137
- try {
138
- const isImage = att.mediaType?.startsWith('image/');
139
- const ext = att.name?.split('.').pop() || (isImage ? 'jpg' : 'bin');
140
- const category = isImage ? 'images' as const : 'documents' as const;
141
- const filePath = saveFile(att.data, category, ext);
142
- storedAttachments.push({ type: att.type, name: att.name, mediaType: att.mediaType, filePath });
143
- } catch (err: any) {
144
- log.warn(`Failed to save attachment ${att.name}: ${err.message}`);
145
- }
146
- }
147
- }
148
-
149
- const msgMeta: any = {};
150
- if (audioPath) msgMeta.audio_data = audioPath;
151
- if (storedAttachments?.length) msgMeta.attachments = JSON.stringify(storedAttachments);
152
- addMessage(convId, 'user', content, Object.keys(msgMeta).length > 0 ? msgMeta : undefined);
153
-
154
- // Route Anthropic provider through Agent SDK
155
- if (freshConfig.ai.provider === 'anthropic') {
156
- let usedTools = false;
157
- startAgentQuery(convId, content, freshConfig.ai.model, (type, data) => {
158
- if (type === 'bot:tool') usedTools = true;
159
- if (type === 'bot:response') {
160
- addMessage(convId, 'assistant', data.content, { model: freshConfig.ai.model });
161
- }
162
- ws.send(JSON.stringify({ type, data: { ...data, conversationId: convId } }));
163
- }, attachments).then(() => {
164
- if (usedTools) runPostIteration(chatWss);
165
- });
166
- return;
167
- }
168
-
169
- // Non-Anthropic providers use ai.chat()
170
- let ai: AiProvider | null = null;
171
- if (freshConfig.ai.provider && (freshConfig.ai.apiKey || freshConfig.ai.provider === 'ollama')) {
172
- ai = createProvider(freshConfig.ai.provider, freshConfig.ai.apiKey, freshConfig.ai.baseUrl);
173
- }
174
-
175
- if (!ai) {
176
- ws.send(JSON.stringify({ type: 'bot:error', data: { conversationId: convId, error: 'AI not configured' } }));
177
- return;
178
- }
179
-
180
- const history = getMessages(convId) as { role: string; content: string }[];
181
- const systemPrompt = getSetting('system_prompt');
182
- const messages: ChatMessage[] = [
183
- ...(systemPrompt ? [{ role: 'system' as const, content: systemPrompt }] : []),
184
- ...history.map((m) => ({ role: m.role as ChatMessage['role'], content: m.content })),
185
- ];
186
-
187
- ws.send(JSON.stringify({ type: 'bot:typing', data: { conversationId: convId } }));
188
- const ctrl = new AbortController();
189
- activeStreams.set(convId, ctrl);
190
-
191
- ai.chat(
192
- messages,
193
- freshConfig.ai.model,
194
- (token) => ws.send(JSON.stringify({ type: 'bot:token', data: { conversationId: convId, token } })),
195
- (full, usage) => {
196
- const m = addMessage(convId, 'assistant', full, { tokens_in: usage?.tokensIn, tokens_out: usage?.tokensOut, model: freshConfig.ai.model });
197
- ws.send(JSON.stringify({ type: 'bot:response', data: { conversationId: convId, messageId: (m as any).id, content: full } }));
198
- activeStreams.delete(convId);
199
- },
200
- (err) => {
201
- ws.send(JSON.stringify({ type: 'bot:error', data: { conversationId: convId, error: err.message } }));
202
- activeStreams.delete(convId);
203
- },
204
- ctrl.signal,
205
- );
78
+ if (!ai) {
79
+ ws.send(JSON.stringify({ type: 'error', error: 'AI not configured. Set up your provider first.' }));
80
+ return;
206
81
  }
207
82
 
208
- if (msg.type === 'user:stop') {
209
- // Stop Agent SDK queries
210
- stopAgentQuery(msg.data.conversationId);
211
- // Stop legacy streams
212
- const ctrl = activeStreams.get(msg.data.conversationId);
213
- if (ctrl) { ctrl.abort(); activeStreams.delete(msg.data.conversationId); }
214
- }
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
+ );
215
93
  });
94
+
95
+ ws.on('close', () => conversations.delete(ws));
216
96
  });
217
97
 
218
98
  server.on('upgrade', (req, socket: net.Socket, head) => {
219
- if (req.url === '/ws') {
220
- 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));
221
101
  return;
222
102
  }
223
103
 
224
- // Proxy all other WS upgrades to Vite (HMR), fallback to worker
225
- const targetPort = isViteAlive() ? vitePort : (isWorkerAlive() ? workerPort : 0);
226
- if (!targetPort) { socket.destroy(); return; }
227
-
228
- const proxy = net.connect(targetPort, () => {
104
+ // Proxy WS upgrade to worker
105
+ const proxy = net.connect(workerPort, () => {
229
106
  const headers = Object.entries(req.headers).map(([k, v]) => `${k}: ${v}`).join('\r\n');
230
107
  proxy.write(`GET ${req.url} HTTP/1.1\r\n${headers}\r\n\r\n`);
231
108
  if (head.length > 0) proxy.write(head);
@@ -248,11 +125,11 @@ export async function startSupervisor() {
248
125
 
249
126
  server.listen(config.port, () => {
250
127
  log.ok(`Supervisor on http://localhost:${config.port}`);
128
+ log.ok(`Fluxy chat at http://localhost:${config.port}/fluxy`);
251
129
  });
252
130
 
253
- // Spawn worker and Vite dev server
131
+ // Spawn worker
254
132
  spawnWorker(workerPort);
255
- await spawnVite(vitePort);
256
133
 
257
134
  // Tunnel
258
135
  let tunnelUrl: string | null = null;
@@ -332,9 +209,7 @@ export async function startSupervisor() {
332
209
  delete latestConfig.tunnelUrl;
333
210
  saveConfig(latestConfig);
334
211
  stopWorker();
335
- stopVite();
336
212
  stopTunnel();
337
- closeDb();
338
213
  server.close();
339
214
  process.exit(0);
340
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 });