fluxy-bot 0.2.1 → 0.2.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fluxy-bot",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Self-hosted AI bot — run your own AI assistant from anywhere",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Lightweight Claude Agent SDK wrapper for the supervisor's fluxy chat.
3
+ * No DB dependency — sessions are tracked in-memory.
4
+ */
5
+
6
+ import { query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+ import os from 'os';
10
+ import { log } from '../shared/logger.js';
11
+
12
+ const CREDENTIALS_FILE = path.join(os.homedir(), '.claude', '.credentials.json');
13
+ const PROMPT_FILE = path.join(import.meta.dirname, '..', 'worker', 'prompts', 'fluxy-system-prompt.txt');
14
+
15
+ // In-memory session tracking (conversationId → sessionId)
16
+ const sessions = new Map<string, string>();
17
+
18
+ interface ActiveQuery {
19
+ abortController: AbortController;
20
+ }
21
+
22
+ const activeQueries = new Map<string, ActiveQuery>();
23
+
24
+ /** Read the OAuth token stored by claude-auth */
25
+ function readOAuthToken(): string | null {
26
+ try {
27
+ if (fs.existsSync(CREDENTIALS_FILE)) {
28
+ const creds = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf-8'));
29
+ if (creds.accessToken) {
30
+ if (creds.expiresAt && Date.now() >= creds.expiresAt) return null;
31
+ return creds.accessToken;
32
+ }
33
+ }
34
+ } catch {}
35
+ return null;
36
+ }
37
+
38
+ /** Read the custom system prompt addendum */
39
+ function readSystemPromptAddendum(): string {
40
+ try {
41
+ return fs.readFileSync(PROMPT_FILE, 'utf-8').trim();
42
+ } catch {
43
+ return '';
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Run an Agent SDK query for a fluxy chat conversation.
49
+ * Streams events back via onMessage callback.
50
+ */
51
+ export async function startFluxyAgentQuery(
52
+ conversationId: string,
53
+ prompt: string,
54
+ model: string,
55
+ onMessage: (type: string, data: any) => void,
56
+ ): Promise<void> {
57
+ const oauthToken = readOAuthToken();
58
+ if (!oauthToken) {
59
+ onMessage('bot:error', { conversationId, error: 'Claude OAuth token not found. Please authenticate via the dashboard.' });
60
+ return;
61
+ }
62
+
63
+ const abortController = new AbortController();
64
+ const existingSessionId = sessions.get(conversationId);
65
+ const addendum = readSystemPromptAddendum();
66
+
67
+ activeQueries.set(conversationId, { abortController });
68
+
69
+ let fullText = '';
70
+
71
+ try {
72
+ const claudeQuery = query({
73
+ prompt,
74
+ options: {
75
+ model,
76
+ cwd: process.cwd(),
77
+ permissionMode: 'bypassPermissions',
78
+ allowDangerouslySkipPermissions: true,
79
+ maxTurns: 50,
80
+ abortController,
81
+ systemPrompt: addendum
82
+ ? { type: 'preset', preset: 'claude_code', append: addendum }
83
+ : { type: 'preset', preset: 'claude_code' },
84
+ ...(existingSessionId ? { resume: existingSessionId } : {}),
85
+ env: {
86
+ ...process.env as Record<string, string>,
87
+ CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
88
+ },
89
+ },
90
+ });
91
+
92
+ onMessage('bot:typing', { conversationId });
93
+
94
+ for await (const msg of claudeQuery) {
95
+ if (abortController.signal.aborted) break;
96
+
97
+ switch (msg.type) {
98
+ case 'assistant': {
99
+ const assistantMsg = msg.message;
100
+ if (!assistantMsg?.content) break;
101
+
102
+ // Save session_id from first assistant message
103
+ if (msg.session_id && !sessions.has(conversationId)) {
104
+ sessions.set(conversationId, msg.session_id);
105
+ }
106
+
107
+ for (const block of assistantMsg.content) {
108
+ if (block.type === 'text' && block.text) {
109
+ fullText += block.text;
110
+ onMessage('bot:token', { conversationId, token: block.text });
111
+ } else if (block.type === 'tool_use') {
112
+ onMessage('bot:tool', { conversationId, name: block.name, input: block.input });
113
+ }
114
+ }
115
+ break;
116
+ }
117
+
118
+ case 'result': {
119
+ if (msg.session_id) {
120
+ sessions.set(conversationId, msg.session_id);
121
+ }
122
+
123
+ if (fullText) {
124
+ onMessage('bot:response', { conversationId, content: fullText });
125
+ fullText = ''; // prevent duplicate
126
+ } else if (msg.subtype?.startsWith('error')) {
127
+ const errorText = (msg as any).errors?.join('; ') || 'Agent query failed';
128
+ onMessage('bot:error', { conversationId, error: errorText });
129
+ }
130
+ break;
131
+ }
132
+
133
+ case 'tool_progress':
134
+ onMessage('bot:tool', {
135
+ conversationId,
136
+ name: (msg as any).tool_name || 'working',
137
+ status: 'running',
138
+ });
139
+ break;
140
+ }
141
+ }
142
+
143
+ // If we accumulated text but didn't hit a result message, send what we have
144
+ if (fullText && !abortController.signal.aborted) {
145
+ onMessage('bot:response', { conversationId, content: fullText });
146
+ }
147
+ } catch (err: any) {
148
+ if (!abortController.signal.aborted) {
149
+ log.warn(`Fluxy agent error (${conversationId}): ${err.message}`);
150
+ onMessage('bot:error', { conversationId, error: err.message });
151
+ }
152
+ } finally {
153
+ activeQueries.delete(conversationId);
154
+ }
155
+ }
156
+
157
+ /** Stop an in-flight query */
158
+ export function stopFluxyAgentQuery(conversationId: string): void {
159
+ const q = activeQueries.get(conversationId);
160
+ if (q) {
161
+ q.abortController.abort();
162
+ activeQueries.delete(conversationId);
163
+ }
164
+ }
165
+
166
+ /** Clear a conversation's session (for "clear context") */
167
+ export function clearFluxySession(conversationId: string): void {
168
+ sessions.delete(conversationId);
169
+ }
@@ -10,6 +10,7 @@ import { log } from '../shared/logger.js';
10
10
  import { startTunnel, stopTunnel, isTunnelAlive, restartTunnel } from './tunnel.js';
11
11
  import { spawnWorker, stopWorker, getWorkerPort, isWorkerAlive } from './worker.js';
12
12
  import { updateTunnelUrl, startHeartbeat, stopHeartbeat, disconnect } from '../shared/relay.js';
13
+ import { startFluxyAgentQuery, stopFluxyAgentQuery } from './fluxy-agent.js';
13
14
 
14
15
  const RECOVERING_HTML = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Recovering</title>
15
16
  <style>body{background:#0a0a0f;color:#94a3b8;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
@@ -123,6 +124,15 @@ export async function startSupervisor() {
123
124
  if (!content) return;
124
125
  if (data.conversationId) convId = data.conversationId;
125
126
 
127
+ // Route Anthropic through Agent SDK (uses OAuth token, not API key)
128
+ if (config.ai.provider === 'anthropic') {
129
+ startFluxyAgentQuery(convId, content, config.ai.model, (type, eventData) => {
130
+ ws.send(JSON.stringify({ type, data: eventData }));
131
+ });
132
+ return;
133
+ }
134
+
135
+ // Other providers: use ai.chat() with conversation history
126
136
  const history = conversations.get(ws) || [];
127
137
  history.push({ role: 'user', content });
128
138
 
@@ -131,7 +141,7 @@ export async function startSupervisor() {
131
141
  return;
132
142
  }
133
143
 
134
- ws.send(JSON.stringify({ type: 'bot:typing' }));
144
+ ws.send(JSON.stringify({ type: 'bot:typing', data: { conversationId: convId } }));
135
145
 
136
146
  ai.chat(
137
147
  [{ role: 'system', content: 'You are Fluxy, a helpful AI assistant. You help users manage and customize their self-hosted bot.' }, ...history],
@@ -147,31 +157,9 @@ export async function startSupervisor() {
147
157
  }
148
158
 
149
159
  if (msg.type === 'user:stop') {
150
- // Stop streaming (best-effort — AI provider may not support cancellation)
160
+ stopFluxyAgentQuery(convId);
151
161
  return;
152
162
  }
153
-
154
- // Legacy protocol support (old fluxy.html format)
155
- if (msg.type === 'message' && msg.content) {
156
- const history = conversations.get(ws) || [];
157
- history.push({ role: 'user', content: msg.content });
158
-
159
- if (!ai) {
160
- ws.send(JSON.stringify({ type: 'error', error: 'AI not configured. Set up your provider first.' }));
161
- return;
162
- }
163
-
164
- ai.chat(
165
- [{ role: 'system', content: 'You are Fluxy, a helpful AI assistant. You help users manage and customize their self-hosted bot.' }, ...history],
166
- config.ai.model,
167
- (token) => ws.send(JSON.stringify({ type: 'token', token })),
168
- (full) => {
169
- history.push({ role: 'assistant', content: full });
170
- ws.send(JSON.stringify({ type: 'done' }));
171
- },
172
- (err) => ws.send(JSON.stringify({ type: 'error', error: err.message })),
173
- );
174
- }
175
163
  });
176
164
 
177
165
  ws.on('close', () => conversations.delete(ws));