@rubixkube/rubix 0.0.1

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.
@@ -0,0 +1,656 @@
1
+ import { getConfig } from "../config/env.js";
2
+ const DEFAULT_APP_NAME = "SRI Agent";
3
+ export class StreamError extends Error {
4
+ reason;
5
+ status;
6
+ rawEvent;
7
+ constructor(message, options = {}) {
8
+ super(message);
9
+ this.name = "StreamError";
10
+ this.reason = options.reason ?? "stream_error";
11
+ this.status = options.status;
12
+ this.rawEvent = options.rawEvent;
13
+ }
14
+ }
15
+ function opelBase() {
16
+ const value = getConfig().opelApiBase;
17
+ if (!value) {
18
+ throw new Error("RUBIXKUBE_OPEL_API_BASE is not set.");
19
+ }
20
+ return value.replace(/\/+$/, "");
21
+ }
22
+ function ensureAuth(auth) {
23
+ const token = auth.idToken ?? auth.authToken;
24
+ const userId = auth.userId ?? auth.userEmail;
25
+ if (!token) {
26
+ throw new Error("Missing auth token. Run /login.");
27
+ }
28
+ if (!userId) {
29
+ throw new Error("Missing user profile (userId/email). Please login again.");
30
+ }
31
+ return {
32
+ token,
33
+ userId,
34
+ tenantId: auth.tenantId,
35
+ };
36
+ }
37
+ function headers(auth, includeTenant = true) {
38
+ const { token, tenantId } = ensureAuth(auth);
39
+ const out = {
40
+ Authorization: `Bearer ${token}`,
41
+ "Content-Type": "application/json",
42
+ };
43
+ if (includeTenant) {
44
+ if (!tenantId) {
45
+ throw new Error("Missing tenant ID. Run /login.");
46
+ }
47
+ out["X-Tenant-ID"] = tenantId;
48
+ }
49
+ return out;
50
+ }
51
+ async function parseJsonResponse(response) {
52
+ if (!response.ok) {
53
+ const text = await response.text();
54
+ throw new StreamError(`HTTP ${response.status}: ${text || response.statusText}`, {
55
+ status: response.status,
56
+ reason: "http_error",
57
+ });
58
+ }
59
+ return (await response.json());
60
+ }
61
+ function mergeChunks(existing, add) {
62
+ if (!existing)
63
+ return add;
64
+ if (!add)
65
+ return existing;
66
+ if (add === existing)
67
+ return existing;
68
+ if (add.startsWith(existing))
69
+ return add;
70
+ if (existing.startsWith(add))
71
+ return existing;
72
+ if (add.includes(existing))
73
+ return add;
74
+ if (existing.includes(add))
75
+ return existing;
76
+ const maxK = Math.min(existing.length, add.length);
77
+ for (let k = maxK; k > 0; k -= 1) {
78
+ if (existing.endsWith(add.slice(0, k))) {
79
+ return existing + add.slice(k);
80
+ }
81
+ }
82
+ return existing + add;
83
+ }
84
+ function asText(value) {
85
+ if (typeof value === "string")
86
+ return value;
87
+ if (value === null || value === undefined)
88
+ return "";
89
+ return JSON.stringify(value);
90
+ }
91
+ function extractFunctionResult(response) {
92
+ if (typeof response === "string")
93
+ return response;
94
+ if (!response || typeof response !== "object")
95
+ return asText(response);
96
+ const obj = response;
97
+ if (typeof obj.result === "string")
98
+ return obj.result;
99
+ if (typeof obj.output === "string")
100
+ return obj.output;
101
+ if (typeof obj.text === "string")
102
+ return obj.text;
103
+ if (Array.isArray(obj.result?.content)) {
104
+ const entries = obj.result.content;
105
+ return entries
106
+ .map((entry) => {
107
+ if (typeof entry === "string")
108
+ return entry;
109
+ if (entry && typeof entry === "object" && typeof entry.text === "string") {
110
+ return entry.text;
111
+ }
112
+ return "";
113
+ })
114
+ .filter((entry) => entry.trim().length > 0)
115
+ .join("\n");
116
+ }
117
+ if (Array.isArray(obj.content)) {
118
+ return obj.content
119
+ .map((entry) => {
120
+ if (typeof entry === "string")
121
+ return entry;
122
+ if (entry && typeof entry === "object" && typeof entry.text === "string") {
123
+ return entry.text;
124
+ }
125
+ return "";
126
+ })
127
+ .filter((entry) => entry.trim().length > 0)
128
+ .join("\n");
129
+ }
130
+ return JSON.stringify(obj);
131
+ }
132
+ function extractFromPayload(value) {
133
+ if (!value || typeof value !== "object")
134
+ return "";
135
+ const record = value;
136
+ const fullText = asText(record.full_text ?? record.fullText).trim();
137
+ if (fullText)
138
+ return fullText;
139
+ const message = asText(record.message).trim();
140
+ if (message)
141
+ return message;
142
+ const text = asText(record.text).trim();
143
+ if (text)
144
+ return text;
145
+ return "";
146
+ }
147
+ function parseToolNameFromText(value) {
148
+ const text = (value ?? "").trim();
149
+ if (!text)
150
+ return "";
151
+ const parenMatch = text.match(/^([a-zA-Z0-9_.:-]+)\s*\(/);
152
+ if (parenMatch?.[1])
153
+ return parenMatch[1];
154
+ const bracketMatch = text.match(/\[([a-zA-Z0-9_.:-]+)\]/);
155
+ if (bracketMatch?.[1])
156
+ return bracketMatch[1];
157
+ const callMatch = text.match(/(?:tool|function)\s+([a-zA-Z0-9_.:-]+)/i);
158
+ if (callMatch?.[1])
159
+ return callMatch[1];
160
+ return "";
161
+ }
162
+ function normalizeWorkflowEvent(type, content, details) {
163
+ return {
164
+ id: `${type}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
165
+ type,
166
+ content,
167
+ ts: Date.now(),
168
+ details,
169
+ };
170
+ }
171
+ export async function listSessions(auth, limit = 20, offset = 0) {
172
+ const { userId } = ensureAuth(auth);
173
+ const url = `${opelBase()}/sessions/?user_id=${encodeURIComponent(userId)}&limit=${limit}&offset=${offset}`;
174
+ const response = await fetch(url, {
175
+ method: "GET",
176
+ headers: headers(auth),
177
+ });
178
+ const payload = await parseJsonResponse(response);
179
+ return (payload.sessions ?? [])
180
+ .filter((session) => !!session?.id)
181
+ .map((session) => ({
182
+ id: session.id ?? "",
183
+ appName: session.app_name ?? DEFAULT_APP_NAME,
184
+ createdAt: session.create_time ?? new Date().toISOString(),
185
+ updatedAt: session.update_time ?? session.create_time ?? new Date().toISOString(),
186
+ title: session.state?.session_title ?? session.title,
187
+ description: session.state?.session_description,
188
+ category: session.state?.session_category,
189
+ clusterId: session.state?.cluster_id,
190
+ }))
191
+ .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
192
+ }
193
+ export async function createSession(auth, appName = DEFAULT_APP_NAME, clusterId) {
194
+ const { userId, tenantId } = ensureAuth(auth);
195
+ if (!tenantId) {
196
+ throw new Error("Missing tenant ID. Run /login.");
197
+ }
198
+ const payload = {
199
+ user_id: userId,
200
+ tenant_id: tenantId,
201
+ app_name: appName,
202
+ state: {
203
+ tenant_id: tenantId,
204
+ user_id: userId,
205
+ user_name: auth.userName ?? "Operator",
206
+ user_email: auth.userEmail ?? userId,
207
+ app_name: appName,
208
+ session_created_at: new Date().toISOString(),
209
+ client_type: "rubix-cli",
210
+ ...(clusterId ? { cluster_id: clusterId, default_namespace: "default" } : {}),
211
+ ...(auth.userRole ? { user_role: auth.userRole } : {}),
212
+ ...(auth.tenantPlan ? { tenant_plan: auth.tenantPlan } : {}),
213
+ },
214
+ };
215
+ const response = await fetch(`${opelBase()}/sessions/`, {
216
+ method: "POST",
217
+ headers: headers(auth),
218
+ body: JSON.stringify(payload),
219
+ });
220
+ const parsed = await parseJsonResponse(response);
221
+ if (!parsed.id) {
222
+ throw new Error("Session created without id.");
223
+ }
224
+ return parsed.id;
225
+ }
226
+ export async function updateSessionState(auth, sessionId, state) {
227
+ const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}?app_name=${encodeURIComponent(DEFAULT_APP_NAME)}`;
228
+ const response = await fetch(url, {
229
+ method: "PUT",
230
+ headers: headers(auth),
231
+ body: JSON.stringify({ state }),
232
+ });
233
+ if (!response.ok) {
234
+ const text = await response.text();
235
+ throw new Error(`Failed to update session (${response.status}): ${text}`);
236
+ }
237
+ }
238
+ export async function getOrCreateSession(auth, preferredId, clusterId) {
239
+ if (preferredId)
240
+ return preferredId;
241
+ const sessions = await listSessions(auth, 50, 0);
242
+ if (sessions.length > 0) {
243
+ const recent = sessions[0];
244
+ // Reuse the session only if it has matching cluster context (or no cluster was requested)
245
+ if (!clusterId || recent.clusterId === clusterId)
246
+ return recent.id;
247
+ }
248
+ return createSession(auth, undefined, clusterId);
249
+ }
250
+ const HEALTHY_STATUSES = new Set(["connected", "healthy", "active"]);
251
+ export async function listClusters(auth) {
252
+ const authBase = getConfig().authApiBase;
253
+ if (!authBase)
254
+ throw new Error("RUBIXKUBE_AUTH_API_BASE is not set.");
255
+ const { token, tenantId } = ensureAuth(auth);
256
+ if (!tenantId) {
257
+ throw new Error("Missing tenant ID. Run /login.");
258
+ }
259
+ const url = `${authBase.replace(/\/+$/, "")}/clusters/?page=1&page_size=50`;
260
+ const response = await fetch(url, {
261
+ method: "GET",
262
+ headers: {
263
+ Authorization: `Bearer ${token}`,
264
+ "Content-Type": "application/json",
265
+ "X-Tenant-ID": tenantId,
266
+ },
267
+ });
268
+ if (!response.ok) {
269
+ const text = await response.text();
270
+ throw new Error(`Failed to load clusters (${response.status}): ${text}`);
271
+ }
272
+ const payload = (await response.json());
273
+ return (payload.clusters ?? [])
274
+ .filter((c) => !!c?.cluster_id)
275
+ .map((c) => ({
276
+ id: c.id ?? c.cluster_id ?? "",
277
+ cluster_id: c.cluster_id ?? "",
278
+ name: c.name ?? c.cluster_id ?? "Unnamed cluster",
279
+ status: (c.status ?? "unknown"),
280
+ region: c.region,
281
+ cluster_type: c.cluster_type,
282
+ }));
283
+ }
284
+ export function firstHealthyCluster(clusters) {
285
+ return clusters.find((c) => HEALTHY_STATUSES.has(c.status)) ?? clusters[0] ?? null;
286
+ }
287
+ function parseParts(content) {
288
+ if (!content)
289
+ return [];
290
+ if (typeof content === "string") {
291
+ try {
292
+ const parsed = JSON.parse(content);
293
+ return parsed.parts ?? [];
294
+ }
295
+ catch {
296
+ return [];
297
+ }
298
+ }
299
+ return content.parts ?? [];
300
+ }
301
+ export async function fetchChatHistory(auth, sessionId, limit = 50) {
302
+ const { userId } = ensureAuth(auth);
303
+ const url = `${opelBase()}/sessions/${encodeURIComponent(sessionId)}/chat-history?user_id=${encodeURIComponent(userId)}&limit=${limit}&offset=0&format=detailed&order_desc=false`;
304
+ const response = await fetch(url, {
305
+ method: "GET",
306
+ headers: headers(auth),
307
+ });
308
+ if (!response.ok) {
309
+ const text = await response.text();
310
+ throw new Error(`Failed to load chat history (${response.status}): ${text}`);
311
+ }
312
+ const payload = (await response.json());
313
+ const messages = [];
314
+ for (const [idx, msg] of (payload.chat_history ?? []).entries()) {
315
+ const role = msg.author === "user" ? "user" : "assistant";
316
+ const parts = parseParts(msg.content);
317
+ const ts = msg.timestamp ? new Date(msg.timestamp).getTime() : Date.now();
318
+ let text = "";
319
+ const workflow = [];
320
+ for (const part of parts) {
321
+ if (part.thought === true)
322
+ continue;
323
+ const fc = part.functionCall ?? part.function_call;
324
+ if (fc) {
325
+ const name = typeof fc.name === "string" ? fc.name : "tool";
326
+ workflow.push({
327
+ id: `hist-fc-${idx}-${name}`,
328
+ type: "function_call",
329
+ content: name,
330
+ ts,
331
+ details: { name, id: fc.id },
332
+ });
333
+ continue;
334
+ }
335
+ const fr = part.functionResponse ?? part.function_response;
336
+ if (fr) {
337
+ const name = typeof fr.name === "string" ? fr.name : "tool";
338
+ workflow.push({
339
+ id: `hist-fr-${idx}-${name}`,
340
+ type: "function_response",
341
+ content: typeof fr.response === "string" ? fr.response : `[${name}]`,
342
+ ts,
343
+ details: { name, id: fr.id },
344
+ });
345
+ continue;
346
+ }
347
+ if (typeof part.text === "string" && part.text.trim()) {
348
+ text = text ? `${text}\n${part.text}` : part.text;
349
+ }
350
+ }
351
+ if (!text.trim() && workflow.length === 0)
352
+ continue;
353
+ messages.push({
354
+ id: msg.invocation_id ? `${msg.invocation_id}-${idx}` : `hist-${idx}`,
355
+ role,
356
+ content: text,
357
+ ts,
358
+ workflow: workflow.length > 0 ? workflow : undefined,
359
+ });
360
+ }
361
+ return messages;
362
+ }
363
+ export async function streamChat(input, callbacks = {}) {
364
+ const { userId } = ensureAuth(input.auth);
365
+ const response = await fetch(`${opelBase()}/chat/${encodeURIComponent(userId)}/session/${encodeURIComponent(input.sessionId)}`, {
366
+ method: "POST",
367
+ headers: headers(input.auth, true),
368
+ signal: input.signal,
369
+ body: JSON.stringify({
370
+ userId,
371
+ sessionId: input.sessionId,
372
+ newMessage: {
373
+ role: "user",
374
+ parts: (input.messageParts ?? [{ text: input.message }]).map((p) => ({ text: p.text })),
375
+ },
376
+ streaming: true,
377
+ minify: true,
378
+ maxTextLen: -1,
379
+ }),
380
+ });
381
+ if (!response.ok) {
382
+ const text = await response.text();
383
+ throw new StreamError(`HTTP ${response.status}: ${text || response.statusText}`, {
384
+ status: response.status,
385
+ reason: "http_error",
386
+ });
387
+ }
388
+ if (!response.body) {
389
+ throw new StreamError("No stream body returned by backend.", { reason: "stream_error" });
390
+ }
391
+ const reader = response.body.getReader();
392
+ const decoder = new TextDecoder();
393
+ let buffer = "";
394
+ let eventDataLines = [];
395
+ let accumulatedText = "";
396
+ let hasContent = false;
397
+ let hasWorkflowEvents = false;
398
+ let chunkCount = 0;
399
+ const handleEvent = (event) => {
400
+ const normalizedEventType = String(event.event_type ?? event.type ?? "").toLowerCase();
401
+ if (normalizedEventType === "stream_complete") {
402
+ return true;
403
+ }
404
+ if (normalizedEventType === "stream_error") {
405
+ throw new StreamError(event.error || event.message || "Stream error", {
406
+ reason: "stream_error",
407
+ rawEvent: event,
408
+ });
409
+ }
410
+ if (normalizedEventType === "stream_terminated") {
411
+ if (hasContent || hasWorkflowEvents)
412
+ return true;
413
+ throw new StreamError("Stream terminated without content.", {
414
+ reason: "stream_error",
415
+ rawEvent: event,
416
+ });
417
+ }
418
+ const stateDelta = event.actions && !Array.isArray(event.actions) && typeof event.actions === "object"
419
+ ? event.actions.stateDelta
420
+ : undefined;
421
+ if (stateDelta) {
422
+ const metadata = {};
423
+ if (stateDelta.session_title)
424
+ metadata.title = stateDelta.session_title;
425
+ if (stateDelta.session_description)
426
+ metadata.description = stateDelta.session_description;
427
+ if (stateDelta.session_category)
428
+ metadata.category = stateDelta.session_category;
429
+ if (Array.isArray(stateDelta.suggestions)) {
430
+ metadata.suggestions = stateDelta.suggestions
431
+ .filter((entry) => typeof entry === "string")
432
+ .map((entry) => String(entry).trim())
433
+ .filter((entry) => entry.length > 0);
434
+ }
435
+ if (Object.keys(metadata).length > 0) {
436
+ callbacks.onSessionMetadata?.(metadata);
437
+ }
438
+ }
439
+ const parts = event.content?.parts ?? [];
440
+ const isThoughtType = normalizedEventType === "thought" || normalizedEventType === "thoughts";
441
+ for (const part of parts) {
442
+ const partText = typeof part.text === "string" ? part.text : "";
443
+ if ((part.thought === true || isThoughtType) && partText.trim()) {
444
+ hasWorkflowEvents = true;
445
+ callbacks.onWorkflow?.(normalizeWorkflowEvent("thought", partText.trim(), {
446
+ partial: event.partial === true,
447
+ }));
448
+ continue;
449
+ }
450
+ const functionCall = part.functionCall ??
451
+ part.function_call;
452
+ if (functionCall) {
453
+ hasWorkflowEvents = true;
454
+ const name = asText(functionCall.name) || "tool";
455
+ const args = functionCall.args ?? {};
456
+ const prettyArgs = Object.keys(args).length > 0 ? `: ${JSON.stringify(args)}` : "";
457
+ callbacks.onWorkflow?.(normalizeWorkflowEvent("function_call", `${name}${prettyArgs}`, {
458
+ name,
459
+ id: functionCall.id,
460
+ }));
461
+ continue;
462
+ }
463
+ const functionResponse = part.functionResponse ??
464
+ part.function_response;
465
+ if (functionResponse) {
466
+ hasWorkflowEvents = true;
467
+ const name = asText(functionResponse.name) || "tool";
468
+ const result = extractFunctionResult(functionResponse.response);
469
+ callbacks.onWorkflow?.(normalizeWorkflowEvent("function_response", result || `[${name}]`, {
470
+ name,
471
+ id: functionResponse.id,
472
+ }));
473
+ continue;
474
+ }
475
+ if (partText && part.thought !== true) {
476
+ const merged = mergeChunks(accumulatedText, partText);
477
+ if (merged !== accumulatedText) {
478
+ accumulatedText = merged;
479
+ hasContent = accumulatedText.trim().length > 0;
480
+ callbacks.onText?.(accumulatedText);
481
+ }
482
+ }
483
+ }
484
+ if (parts.length === 0) {
485
+ if (isThoughtType) {
486
+ const thoughtText = asText(event.message || event.text).trim() ||
487
+ extractFromPayload(event.payload) ||
488
+ extractFromPayload(event.data);
489
+ if (thoughtText) {
490
+ hasWorkflowEvents = true;
491
+ callbacks.onWorkflow?.(normalizeWorkflowEvent("thought", thoughtText, {
492
+ partial: event.partial === true,
493
+ }));
494
+ }
495
+ }
496
+ if (normalizedEventType === "function_call") {
497
+ const functionCall = event.functionCall ??
498
+ event.function_call ??
499
+ event.payload?.functionCall ??
500
+ event.payload?.function_call ??
501
+ event.data?.functionCall ??
502
+ event.data?.function_call;
503
+ const fallbackText = asText(event.message || event.text).trim() ||
504
+ extractFromPayload(event.payload) ||
505
+ extractFromPayload(event.data);
506
+ const name = asText(functionCall?.name).trim() ||
507
+ asText(event.name).trim() ||
508
+ parseToolNameFromText(fallbackText) ||
509
+ "tool";
510
+ const args = functionCall?.args ?? {};
511
+ const prettyArgs = Object.keys(args).length > 0 ? `: ${JSON.stringify(args)}` : "";
512
+ const content = prettyArgs ? `${name}${prettyArgs}` : name;
513
+ hasWorkflowEvents = true;
514
+ callbacks.onWorkflow?.(normalizeWorkflowEvent("function_call", content, {
515
+ name,
516
+ id: functionCall?.id ?? event.id,
517
+ }));
518
+ }
519
+ if (normalizedEventType === "function_response") {
520
+ const functionResponse = event.functionResponse ??
521
+ event.function_response ??
522
+ event.payload?.functionResponse ??
523
+ event.payload?.function_response ??
524
+ event.data?.functionResponse ??
525
+ event.data?.function_response;
526
+ const name = asText(functionResponse?.name).trim() ||
527
+ asText(event.name).trim() ||
528
+ parseToolNameFromText(asText(event.message || event.text)) ||
529
+ "tool";
530
+ const result = extractFunctionResult(functionResponse?.response) ||
531
+ asText(event.message || event.text).trim() ||
532
+ extractFromPayload(event.payload) ||
533
+ extractFromPayload(event.data) ||
534
+ `[${name}]`;
535
+ hasWorkflowEvents = true;
536
+ callbacks.onWorkflow?.(normalizeWorkflowEvent("function_response", result, {
537
+ name,
538
+ id: functionResponse?.id ?? event.id,
539
+ }));
540
+ }
541
+ }
542
+ if (event.text && parts.length === 0) {
543
+ const merged = mergeChunks(accumulatedText, event.text);
544
+ if (merged !== accumulatedText) {
545
+ accumulatedText = merged;
546
+ hasContent = accumulatedText.trim().length > 0;
547
+ callbacks.onText?.(accumulatedText);
548
+ }
549
+ }
550
+ return false;
551
+ };
552
+ try {
553
+ const flushEvent = () => {
554
+ const payload = eventDataLines.join("\n").trim();
555
+ eventDataLines = [];
556
+ if (!payload || payload === "[DONE]")
557
+ return false;
558
+ let event;
559
+ try {
560
+ event = JSON.parse(payload);
561
+ }
562
+ catch {
563
+ return false;
564
+ }
565
+ chunkCount += 1;
566
+ return handleEvent(event);
567
+ };
568
+ const maybeDispatchSingleLine = () => {
569
+ if (eventDataLines.length !== 1)
570
+ return false;
571
+ const payload = eventDataLines[0]?.trim() ?? "";
572
+ if (!payload || payload === "[DONE]") {
573
+ eventDataLines = [];
574
+ return false;
575
+ }
576
+ let event;
577
+ try {
578
+ event = JSON.parse(payload);
579
+ }
580
+ catch {
581
+ return false;
582
+ }
583
+ // Handle non-standard streams that omit blank-line event delimiters.
584
+ eventDataLines = [];
585
+ chunkCount += 1;
586
+ return handleEvent(event);
587
+ };
588
+ const processLine = (rawLine) => {
589
+ const line = rawLine.replace(/\r$/, "");
590
+ if (!line.trim()) {
591
+ return flushEvent();
592
+ }
593
+ if (line.startsWith(":")) {
594
+ return false;
595
+ }
596
+ if (line.startsWith("event:") || line.startsWith("id:") || line.startsWith("retry:")) {
597
+ return false;
598
+ }
599
+ if (line.startsWith("data:")) {
600
+ eventDataLines.push(line.slice(5).trimStart());
601
+ }
602
+ else if (line.trimStart().startsWith("{") || line.trimStart().startsWith("[")) {
603
+ // Tolerate raw JSON lines from non-SSE-compliant streams.
604
+ eventDataLines.push(line.trimStart());
605
+ }
606
+ return maybeDispatchSingleLine();
607
+ };
608
+ while (true) {
609
+ const { value, done } = await reader.read();
610
+ if (done)
611
+ break;
612
+ buffer += decoder.decode(value, { stream: true });
613
+ const lines = buffer.split("\n");
614
+ buffer = lines.pop() ?? "";
615
+ for (const line of lines) {
616
+ const completed = processLine(line);
617
+ if (completed) {
618
+ return { text: accumulatedText };
619
+ }
620
+ }
621
+ }
622
+ if (buffer.trim().length > 0) {
623
+ const completed = processLine(buffer);
624
+ if (completed) {
625
+ return { text: accumulatedText };
626
+ }
627
+ }
628
+ if (eventDataLines.length > 0) {
629
+ const completed = flushEvent();
630
+ if (completed) {
631
+ return { text: accumulatedText };
632
+ }
633
+ }
634
+ // Stream ended naturally — match console behavior: complete if we got any chunks
635
+ if (chunkCount === 0) {
636
+ throw new StreamError("Stream ended without any response from the agent.", { reason: "stream_error" });
637
+ }
638
+ return { text: accumulatedText };
639
+ }
640
+ catch (error) {
641
+ if (error instanceof StreamError) {
642
+ throw error;
643
+ }
644
+ const abortError = (error instanceof DOMException && error.name === "AbortError") ||
645
+ (error?.name === "AbortError");
646
+ if (abortError) {
647
+ throw new StreamError("Stream cancelled by user.", { reason: "user_cancelled" });
648
+ }
649
+ throw new StreamError(error instanceof Error ? error.message : String(error), {
650
+ reason: "network_error",
651
+ });
652
+ }
653
+ finally {
654
+ reader.releaseLock();
655
+ }
656
+ }
@@ -0,0 +1,51 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const CONFIG_DIR = ".rubix";
5
+ const TRUST_FILE = "trusted-folders.json";
6
+ function getTrustPath() {
7
+ return path.join(os.homedir(), CONFIG_DIR, TRUST_FILE);
8
+ }
9
+ async function ensureConfigDir() {
10
+ await fs.mkdir(path.join(os.homedir(), CONFIG_DIR), { recursive: true, mode: 0o700 });
11
+ }
12
+ export async function loadTrustConfig() {
13
+ try {
14
+ const data = await fs.readFile(getTrustPath(), "utf8");
15
+ return JSON.parse(data);
16
+ }
17
+ catch (error) {
18
+ const asNodeError = error;
19
+ if (asNodeError.code === "ENOENT") {
20
+ return { folders: [], timestamp: Date.now() };
21
+ }
22
+ throw error;
23
+ }
24
+ }
25
+ export async function isFolderTrusted(folderPath) {
26
+ const config = await loadTrustConfig();
27
+ const resolved = path.resolve(folderPath);
28
+ return config.folders.includes(resolved);
29
+ }
30
+ export async function trustFolder(folderPath) {
31
+ await ensureConfigDir();
32
+ const config = await loadTrustConfig();
33
+ const resolved = path.resolve(folderPath);
34
+ if (!config.folders.includes(resolved)) {
35
+ config.folders.push(resolved);
36
+ config.timestamp = Date.now();
37
+ await fs.writeFile(getTrustPath(), JSON.stringify(config, null, 2), {
38
+ mode: 0o600,
39
+ });
40
+ }
41
+ }
42
+ export async function untrustFolder(folderPath) {
43
+ await ensureConfigDir();
44
+ const config = await loadTrustConfig();
45
+ const resolved = path.resolve(folderPath);
46
+ config.folders = config.folders.filter((f) => f !== resolved);
47
+ config.timestamp = Date.now();
48
+ await fs.writeFile(getTrustPath(), JSON.stringify(config, null, 2), {
49
+ mode: 0o600,
50
+ });
51
+ }